Feature: Workflows (#5121)

This commit is contained in:
shamoon
2024-01-03 00:19:19 -08:00
committed by GitHub
parent 46e6be319f
commit 3b6ce16f1c
54 changed files with 4980 additions and 2011 deletions

View File

@@ -9,8 +9,11 @@ class DocumentsConfig(AppConfig):
def ready(self):
from documents.signals import document_consumption_finished
from documents.signals import document_updated
from documents.signals.handlers import add_inbox_tags
from documents.signals.handlers import add_to_index
from documents.signals.handlers import run_workflow_added
from documents.signals.handlers import run_workflow_updated
from documents.signals.handlers import set_correspondent
from documents.signals.handlers import set_document_type
from documents.signals.handlers import set_log_entry
@@ -24,5 +27,7 @@ class DocumentsConfig(AppConfig):
document_consumption_finished.connect(set_storage_path)
document_consumption_finished.connect(set_log_entry)
document_consumption_finished.connect(add_to_index)
document_consumption_finished.connect(run_workflow_added)
document_updated.connect(run_workflow_updated)
AppConfig.ready(self)

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 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
@@ -602,66 +603,71 @@ class Consumer(LoggingMixin):
return document
def get_template_overrides(
def get_workflow_overrides(
self,
input_doc: ConsumableDocument,
) -> DocumentMetadataOverrides:
"""
Match consumption templates to a document based on source and
file name filters, path filters or mail rule filter if specified
Get overrides from matching workflows
"""
overrides = DocumentMetadataOverrides()
for template in ConsumptionTemplate.objects.all().order_by("order"):
for workflow in Workflow.objects.filter(enabled=True).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 document_matches_workflow(
input_doc,
workflow,
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
):
for action in workflow.actions.all():
self.log.info(
f"Applying overrides in {action} from {workflow}",
)
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 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:
"""
Consumption template title placeholders can only include items that are
assigned as part of this template (since auto-matching hasnt happened yet)
"""
local_added = timezone.localtime(timezone.now())
correspondent_name = (
@@ -680,20 +686,14 @@ class Consumer(LoggingMixin):
else None
)
return title.format(
correspondent=correspondent_name,
document_type=doc_type_name,
added=local_added.isoformat(),
added_year=local_added.strftime("%Y"),
added_year_short=local_added.strftime("%y"),
added_month=local_added.strftime("%m"),
added_month_name=local_added.strftime("%B"),
added_month_name_short=local_added.strftime("%b"),
added_day=local_added.strftime("%d"),
owner_username=owner_username,
original_filename=Path(self.filename).stem,
added_time=local_added.strftime("%H:%M"),
).strip()
return parse_doc_title_w_placeholders(
title,
correspondent_name,
doc_type_name,
owner_username,
local_added,
self.filename,
)
def _store(
self,
@@ -846,3 +846,47 @@ class Consumer(LoggingMixin):
self.log.warning("Script stderr:")
for line in stderr_str:
self.log.warning(line)
def parse_doc_title_w_placeholders(
title: str,
correspondent_name: str,
doc_type_name: str,
owner_username: str,
local_added: datetime.datetime,
original_filename: str,
created: Optional[datetime.datetime] = None,
) -> str:
"""
Available title placeholders for Workflows depend on what has already been assigned,
e.g. for pre-consumption triggers created will not have been parsed yet, but it will
for added / updated triggers
"""
formatting = {
"correspondent": correspondent_name,
"document_type": doc_type_name,
"added": local_added.isoformat(),
"added_year": local_added.strftime("%Y"),
"added_year_short": local_added.strftime("%y"),
"added_month": local_added.strftime("%m"),
"added_month_name": local_added.strftime("%B"),
"added_month_name_short": local_added.strftime("%b"),
"added_day": local_added.strftime("%d"),
"added_time": local_added.strftime("%H:%M"),
"owner_username": owner_username,
"original_filename": Path(original_filename).stem,
}
if created is not None:
formatting.update(
{
"created": created.isoformat(),
"created_year": created.strftime("%Y"),
"created_year_short": created.strftime("%y"),
"created_month": created.strftime("%m"),
"created_month_name": created.strftime("%B"),
"created_month_name_short": created.strftime("%b"),
"created_day": created.strftime("%d"),
"created_time": created.strftime("%H:%M"),
},
)
return title.format(**formatting).strip()

View File

@@ -33,21 +33,20 @@ class DocumentMetadataOverrides:
def update(self, other: "DocumentMetadataOverrides") -> "DocumentMetadataOverrides":
"""
Merges two DocumentMetadataOverrides objects such that object B's overrides
are only applied if the property is empty in object A or merged if multiple
are accepted.
are applied to object A or merged if multiple are accepted.
The update is an in-place modification of self
"""
# only if empty
if self.title is None:
if other.title is not None:
self.title = other.title
if self.correspondent_id is None:
if other.correspondent_id is not None:
self.correspondent_id = other.correspondent_id
if self.document_type_id is None:
if other.document_type_id is not None:
self.document_type_id = other.document_type_id
if self.storage_path_id is None:
if other.storage_path_id is not None:
self.storage_path_id = other.storage_path_id
if self.owner_id is None:
if other.owner_id is not None:
self.owner_id = other.owner_id
# merge

View File

@@ -23,7 +23,6 @@ from guardian.models import UserObjectPermission
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_filename
from documents.models import ConsumptionTemplate
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -35,6 +34,9 @@ from documents.models import SavedViewFilterRule
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.settings import EXPORTER_ARCHIVE_NAME
from documents.settings import EXPORTER_FILE_NAME
from documents.settings import EXPORTER_THUMBNAIL_NAME
@@ -285,7 +287,15 @@ class Command(BaseCommand):
)
manifest += json.loads(
serializers.serialize("json", ConsumptionTemplate.objects.all()),
serializers.serialize("json", WorkflowTrigger.objects.all()),
)
manifest += json.loads(
serializers.serialize("json", WorkflowAction.objects.all()),
)
manifest += json.loads(
serializers.serialize("json", Workflow.objects.all()),
)
manifest += json.loads(

View File

@@ -1,27 +1,35 @@
import logging
import re
from fnmatch import fnmatch
from typing import Union
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")
def log_reason(matching_model: MatchingModel, document: Document, reason: str):
def log_reason(
matching_model: Union[MatchingModel, WorkflowTrigger],
document: Document,
reason: str,
):
class_name = type(matching_model).__name__
name = (
matching_model.name if hasattr(matching_model, "name") else str(matching_model)
)
logger.debug(
f"{class_name} {matching_model.name} matched on document "
f"{document} because {reason}",
f"{class_name} {name} matched on document {document} because {reason}",
)
@@ -237,65 +245,182 @@ def _split_match(matching_model):
]
def document_matches_template(
def consumable_document_matches_workflow(
document: ConsumableDocument,
template: ConsumptionTemplate,
) -> bool:
trigger: WorkflowTrigger,
) -> tuple[bool, str]:
"""
Returns True if the incoming document matches all filters and
settings from the template, False otherwise
Returns True if the ConsumableDocument matches all filters from the workflow trigger,
False otherwise. Includes a reason if doesn't match
"""
def log_match_failure(reason: str):
logger.info(f"Document did not match template {template.name}")
logger.debug(reason)
trigger_matched = True
reason = ""
# Document source vs template source
if document.source not in [int(x) for x in list(template.sources)]:
log_match_failure(
# Document source vs trigger source
if document.source not in [int(x) for x in list(trigger.sources)]:
reason = (
f"Document source {document.source.name} not in"
f" {[DocumentSource(int(x)).name for x in template.sources]}",
f" {[DocumentSource(int(x)).name for x in trigger.sources]}",
)
return False
trigger_matched = False
# Document mail rule vs template mail rule
# Document mail rule vs trigger mail rule
if (
document.mailrule_id is not None
and template.filter_mailrule is not None
and document.mailrule_id != template.filter_mailrule.pk
and trigger.filter_mailrule is not None
and document.mailrule_id != trigger.filter_mailrule.pk
):
log_match_failure(
reason = (
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 trigger filename
if (
template.filter_filename is not None
and len(template.filter_filename) > 0
trigger.filter_filename is not None
and len(trigger.filter_filename) > 0
and not fnmatch(
document.original_file.name.lower(),
template.filter_filename.lower(),
trigger.filter_filename.lower(),
)
):
log_match_failure(
reason = (
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 trigger path
if (
template.filter_path is not None
and len(template.filter_path) > 0
and not document.original_file.match(template.filter_path)
trigger.filter_path is not None
and len(trigger.filter_path) > 0
and not document.original_file.match(trigger.filter_path)
):
log_match_failure(
reason = (
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}")
return True
return (trigger_matched, reason)
def existing_document_matches_workflow(
document: Document,
trigger: WorkflowTrigger,
) -> tuple[bool, str]:
"""
Returns True if the Document matches all filters from the workflow trigger,
False otherwise. Includes a reason if doesn't match
"""
trigger_matched = True
reason = ""
if trigger.matching_algorithm > MatchingModel.MATCH_NONE and not matches(
trigger,
document,
):
reason = (
f"Document content matching settings for algorithm '{trigger.matching_algorithm}' did not match",
)
trigger_matched = False
# Document tags vs trigger has_tags
if (
trigger.filter_has_tags.all().count() > 0
and document.tags.filter(
id__in=trigger.filter_has_tags.all().values_list("id"),
).count()
== 0
):
reason = (
f"Document tags {document.tags.all()} do not include"
f" {trigger.filter_has_tags.all()}",
)
trigger_matched = False
# Document correpondent vs trigger has_correspondent
if (
trigger.filter_has_correspondent is not None
and document.correspondent != trigger.filter_has_correspondent
):
reason = (
f"Document correspondent {document.correspondent} does not match {trigger.filter_has_correspondent}",
)
trigger_matched = False
# Document document_type vs trigger has_document_type
if (
trigger.filter_has_document_type is not None
and document.document_type != trigger.filter_has_document_type
):
reason = (
f"Document doc type {document.document_type} does not match {trigger.filter_has_document_type}",
)
trigger_matched = False
# Document original_filename vs trigger filename
if (
trigger.filter_filename is not None
and len(trigger.filter_filename) > 0
and document.original_filename is not None
and not fnmatch(
document.original_filename.lower(),
trigger.filter_filename.lower(),
)
):
reason = (
f"Document filename {document.original_filename} does not match"
f" {trigger.filter_filename.lower()}",
)
trigger_matched = False
return (trigger_matched, reason)
def document_matches_workflow(
document: Union[ConsumableDocument, Document],
workflow: Workflow,
trigger_type: WorkflowTrigger.WorkflowTriggerType,
) -> bool:
"""
Returns True if the ConsumableDocument or Document matches all filters and
settings from the workflow trigger, False otherwise
"""
trigger_matched = True
if workflow.triggers.filter(type=trigger_type).count() == 0:
trigger_matched = False
logger.info(f"Document did not match {workflow}")
logger.debug(f"No matching triggers with type {trigger_type} found")
else:
for trigger in workflow.triggers.filter(type=trigger_type):
if trigger_type == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION:
trigger_matched, reason = consumable_document_matches_workflow(
document,
trigger,
)
elif (
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
):
trigger_matched, reason = existing_document_matches_workflow(
document,
trigger,
)
else:
# New trigger types need to be explicitly checked above
raise Exception(f"Trigger type {trigger_type} not yet supported")
if trigger_matched:
logger.info(f"Document matched {trigger} from {workflow}")
# matched, bail early
return True
else:
logger.info(f"Document did not match {workflow}")
logger.debug(reason)
return trigger_matched

View File

@@ -0,0 +1,513 @@
# 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",
)
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.triggers.first().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()
def delete_consumption_template_content_type(apps, schema_editor):
with transaction.atomic():
apps.get_model("contenttypes", "ContentType").objects.filter(
app_label="documents",
model="consumptiontemplate",
).delete()
def undelete_consumption_template_content_type(apps, schema_editor):
apps.get_model("contenttypes", "ContentType").objects.create(
app_label="documents",
model="consumptiontemplate",
)
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")),
(
"enabled",
models.BooleanField(default=True, verbose_name="enabled"),
),
],
),
migrations.CreateModel(
name="WorkflowAction",
fields=[
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"type",
models.PositiveIntegerField(
choices=[(1, "Assignment")],
default=1,
verbose_name="Workflow Action Type",
),
),
(
"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 Started"),
(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",
),
),
(
"matching_algorithm",
models.PositiveIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
],
default=0,
verbose_name="matching algorithm",
),
),
(
"match",
models.CharField(blank=True, max_length=256, verbose_name="match"),
),
(
"is_insensitive",
models.BooleanField(default=True, verbose_name="is insensitive"),
),
(
"filter_has_tags",
models.ManyToManyField(
blank=True,
to="documents.tag",
verbose_name="has these tag(s)",
),
),
(
"filter_has_document_type",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="documents.documenttype",
verbose_name="has this document type",
),
),
(
"filter_has_correspondent",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="documents.correspondent",
verbose_name="has this correspondent",
),
),
],
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"),
migrations.RunPython(
delete_consumption_template_content_type,
undelete_consumption_template_content_type,
),
]

View File

@@ -888,15 +888,31 @@ if settings.AUDIT_LOG_ENABLED:
auditlog.register(CustomFieldInstance)
class ConsumptionTemplate(models.Model):
class WorkflowTrigger(models.Model):
class WorkflowTriggerMatching(models.IntegerChoices):
# No auto matching
NONE = MatchingModel.MATCH_NONE, _("None")
ANY = MatchingModel.MATCH_ANY, _("Any word")
ALL = MatchingModel.MATCH_ALL, _("All words")
LITERAL = MatchingModel.MATCH_LITERAL, _("Exact match")
REGEX = MatchingModel.MATCH_REGEX, _("Regular expression")
FUZZY = MatchingModel.MATCH_FUZZY, _("Fuzzy word")
class WorkflowTriggerType(models.IntegerChoices):
CONSUMPTION = 1, _("Consumption Started")
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 +952,56 @@ class ConsumptionTemplate(models.Model):
verbose_name=_("filter documents from this mail rule"),
)
match = models.CharField(_("match"), max_length=256, blank=True)
matching_algorithm = models.PositiveIntegerField(
_("matching algorithm"),
choices=WorkflowTriggerMatching.choices,
default=WorkflowTriggerMatching.NONE,
)
is_insensitive = models.BooleanField(_("is insensitive"), default=True)
filter_has_tags = models.ManyToManyField(
Tag,
blank=True,
verbose_name=_("has these tag(s)"),
)
filter_has_document_type = models.ForeignKey(
DocumentType,
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name=_("has this document type"),
)
filter_has_correspondent = models.ForeignKey(
Correspondent,
null=True,
blank=True,
on_delete=models.SET_NULL,
verbose_name=_("has this correspondent"),
)
class Meta:
verbose_name = _("workflow trigger")
verbose_name_plural = _("workflow triggers")
def __str__(self):
return f"WorkflowTrigger {self.pk}"
class WorkflowAction(models.Model):
class WorkflowActionType(models.IntegerChoices):
ASSIGNMENT = 1, _("Assignment")
type = models.PositiveIntegerField(
_("Workflow Action Type"),
choices=WorkflowActionType.choices,
default=WorkflowActionType.ASSIGNMENT,
)
assign_title = models.CharField(
_("assign title"),
max_length=256,
@@ -1022,8 +1088,33 @@ 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"),
)
enabled = models.BooleanField(_("enabled"), default=True)
def __str__(self):
return f"Workflow: {self.name}"

View File

@@ -27,7 +27,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
@@ -41,6 +40,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
@@ -1278,43 +1280,38 @@ class BulkEditObjectPermissionsSerializer(serializers.Serializer, SetPermissions
return attrs
class ConsumptionTemplateSerializer(serializers.ModelSerializer):
order = serializers.IntegerField(required=False)
class WorkflowTriggerSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False, allow_null=True)
sources = fields.MultipleChoiceField(
choices=ConsumptionTemplate.DocumentSourceChoices.choices,
allow_empty=False,
choices=WorkflowTrigger.DocumentSourceChoices.choices,
allow_empty=True,
default={
DocumentSource.ConsumeFolder,
DocumentSource.ApiUpload,
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",
"matching_algorithm",
"match",
"is_insensitive",
"filter_has_tags",
"filter_has_correspondent",
"filter_has_document_type",
]
def validate(self, attrs):
@@ -1322,12 +1319,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
@@ -1342,7 +1333,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)
):
@@ -1351,3 +1343,144 @@ class ConsumptionTemplateSerializer(serializers.ModelSerializer):
)
return attrs
class WorkflowActionSerializer(serializers.ModelSerializer):
id = serializers.IntegerField(required=False, allow_null=True)
assign_correspondent = CorrespondentField(allow_null=True, required=False)
assign_tags = TagsField(many=True, allow_null=True, required=False)
assign_document_type = DocumentTypeField(allow_null=True, required=False)
assign_storage_path = StoragePathField(allow_null=True, required=False)
class Meta:
model = WorkflowAction
fields = [
"id",
"type",
"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",
"enabled",
"triggers",
"actions",
]
def update_triggers_and_actions(self, instance: Workflow, triggers, actions):
set_triggers = []
set_actions = []
if triggers is not None:
for trigger in triggers:
filter_has_tags = trigger.pop("filter_has_tags", None)
trigger_instance, _ = WorkflowTrigger.objects.update_or_create(
id=trigger["id"] if "id" in trigger else None,
defaults=trigger,
)
if filter_has_tags is not None:
trigger_instance.filter_has_tags.set(filter_has_tags)
set_triggers.append(trigger_instance)
if actions is not None:
for action in actions:
assign_tags = action.pop("assign_tags", None)
assign_view_users = action.pop("assign_view_users", None)
assign_view_groups = action.pop("assign_view_groups", None)
assign_change_users = action.pop("assign_change_users", None)
assign_change_groups = action.pop("assign_change_groups", None)
assign_custom_fields = action.pop("assign_custom_fields", None)
action_instance, _ = WorkflowAction.objects.update_or_create(
id=action["id"] if "id" in action else None,
defaults=action,
)
if assign_tags is not None:
action_instance.assign_tags.set(assign_tags)
if assign_view_users is not None:
action_instance.assign_view_users.set(assign_view_users)
if assign_view_groups is not None:
action_instance.assign_view_groups.set(assign_view_groups)
if assign_change_users is not None:
action_instance.assign_change_users.set(assign_change_users)
if assign_change_groups is not None:
action_instance.assign_change_groups.set(assign_change_groups)
if assign_custom_fields is not None:
action_instance.assign_custom_fields.set(assign_custom_fields)
set_actions.append(action_instance)
instance.triggers.set(set_triggers)
instance.actions.set(set_actions)
instance.save()
def prune_triggers_and_actions(self):
"""
ManyToMany fields dont support e.g. on_delete so we need to discard unattached
triggers and actionas manually
"""
for trigger in WorkflowTrigger.objects.all():
if trigger.workflows.all().count() == 0:
trigger.delete()
for action in WorkflowAction.objects.all():
if action.workflows.all().count() == 0:
action.delete()
def create(self, validated_data) -> Workflow:
if "triggers" in validated_data:
triggers = validated_data.pop("triggers")
if "actions" in validated_data:
actions = validated_data.pop("actions")
instance = super().create(validated_data)
self.update_triggers_and_actions(instance, triggers, actions)
return instance
def update(self, instance: Workflow, validated_data) -> Workflow:
if "triggers" in validated_data:
triggers = validated_data.pop("triggers")
if "actions" in validated_data:
actions = validated_data.pop("actions")
instance = super().update(instance, validated_data)
self.update_triggers_and_actions(instance, triggers, actions)
self.prune_triggers_and_actions()
return instance

View File

@@ -3,3 +3,4 @@ from django.dispatch import Signal
document_consumption_started = Signal()
document_consumption_finished = Signal()
document_consumer_declaration = Signal()
document_updated = Signal()

View File

@@ -24,14 +24,19 @@ from filelock import FileLock
from documents import matching
from documents.classifier import DocumentClassifier
from documents.consumer import parse_doc_title_w_placeholders
from documents.file_handling import create_source_path_directory
from documents.file_handling import delete_empty_directories
from documents.file_handling import generate_unique_filename
from documents.models import CustomFieldInstance
from documents.models import Document
from documents.models import MatchingModel
from documents.models import PaperlessTask
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 set_permissions_for_object
logger = logging.getLogger("paperless.handlers")
@@ -514,6 +519,105 @@ def add_to_index(sender, document, **kwargs):
index.add_or_update_document(document)
def run_workflow_added(sender, document: Document, logging_group=None, **kwargs):
run_workflow(
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
document,
logging_group,
)
def run_workflow_updated(sender, document: Document, logging_group=None, **kwargs):
run_workflow(
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
document,
logging_group,
)
def run_workflow(
trigger_type: WorkflowTrigger.WorkflowTriggerType,
document: Document,
logging_group=None,
):
for workflow in Workflow.objects.filter(
enabled=True,
triggers__type=trigger_type,
).order_by("order"):
if matching.document_matches_workflow(
document,
workflow,
trigger_type,
):
for action in workflow.actions.all():
logger.info(
f"Applying {action} from {workflow}",
extra={"group": logging_group},
)
if action.assign_tags.all().count() > 0:
document.tags.add(*action.assign_tags.all())
if action.assign_correspondent is not None:
document.correspondent = action.assign_correspondent
if action.assign_document_type is not None:
document.document_type = action.assign_document_type
if action.assign_storage_path is not None:
document.storage_path = action.assign_storage_path
if action.assign_owner is not None:
document.owner = action.assign_owner
if action.assign_title is not None:
document.title = parse_doc_title_w_placeholders(
action.assign_title,
document.correspondent.name
if document.correspondent is not None
else "",
document.document_type.name
if document.document_type is not None
else "",
document.owner.username if document.owner is not None else "",
document.added,
document.original_filename,
document.created,
)
if (
action.assign_view_users is not None
or action.assign_view_groups is not None
or action.assign_change_users is not None
or action.assign_change_groups is not None
):
permissions = {
"view": {
"users": action.assign_view_users.all().values_list("id")
or [],
"groups": action.assign_view_groups.all().values_list("id")
or [],
},
"change": {
"users": action.assign_change_users.all().values_list("id")
or [],
"groups": action.assign_change_groups.all().values_list(
"id",
)
or [],
},
}
set_permissions_for_object(permissions=permissions, object=document)
if action.assign_custom_fields is not None:
for field in action.assign_custom_fields.all():
CustomFieldInstance.objects.create(
field=field,
document=document,
) # adds to document
document.save()
@before_task_publish.connect
def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
"""

View File

@@ -36,6 +36,7 @@ from documents.models import Tag
from documents.parsers import DocumentParser
from documents.parsers import get_parser_class_for_mime_type
from documents.sanity_checker import SanityCheckFailedException
from documents.signals import document_updated
if settings.AUDIT_LOG_ENABLED:
import json
@@ -157,7 +158,7 @@ def consume_file(
overrides.asn = reader.asn
logger.info(f"Found ASN in barcode: {overrides.asn}")
template_overrides = Consumer().get_template_overrides(
template_overrides = Consumer().get_workflow_overrides(
input_doc=input_doc,
)
@@ -215,6 +216,11 @@ def bulk_update_documents(document_ids):
ix = index.open_index()
for doc in documents:
document_updated.send(
sender=None,
document=doc,
logging_group=uuid.uuid4(),
)
post_save.send(Document, instance=doc, created=False)
with AsyncWriter(ix) as writer:

View File

@@ -1,236 +0,0 @@
import json
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
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.tests.utils import DirectoriesMixin
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
class TestApiConsumptionTemplates(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/consumption_templates/"
def setUp(self) -> None:
super().setUp()
user = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=user)
self.user2 = User.objects.create(username="user2")
self.user3 = User.objects.create(username="user3")
self.group1 = Group.objects.create(name="group1")
self.c = Correspondent.objects.create(name="Correspondent Name")
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
self.dt = DocumentType.objects.create(name="DocType Name")
self.t1 = Tag.objects.create(name="t1")
self.t2 = Tag.objects.create(name="t2")
self.t3 = Tag.objects.create(name="t3")
self.sp = StoragePath.objects.create(path="/test/")
self.cf1 = CustomField.objects.create(name="Custom Field 1", data_type="string")
self.cf2 = CustomField.objects.create(
name="Custom Field 2",
data_type="integer",
)
self.ct = ConsumptionTemplate.objects.create(
name="Template 1",
order=0,
sources=f"{int(DocumentSource.ApiUpload)},{int(DocumentSource.ConsumeFolder)},{int(DocumentSource.MailFetch)}",
filter_filename="*simple*",
filter_path="*/samples/*",
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()
def test_api_get_consumption_template(self):
"""
GIVEN:
- API request to get all consumption template
WHEN:
- API is called
THEN:
- Existing consumption templates 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)
self.assertEqual(
resp_consumption_template["assign_correspondent"],
self.ct.assign_correspondent.pk,
)
def test_api_create_consumption_template(self):
"""
GIVEN:
- API request to create a consumption template
WHEN:
- API is called
THEN:
- Correct HTTP response
- New template is created
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Template 2",
"order": 1,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "*test*",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(ConsumptionTemplate.objects.count(), 2)
def test_api_create_invalid_consumption_template(self):
"""
GIVEN:
- API request to create a consumption template
- Neither file name nor path filter are specified
WHEN:
- API is called
THEN:
- Correct HTTP 400 response
- No template is created
"""
response = self.client.post(
self.ENDPOINT,
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):
"""
GIVEN:
- API request to create a consumption template
- Path or filename filter or assign title are empty string
WHEN:
- API is called
THEN:
- Template is created but filter or title assignment is not set if ""
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Template 2",
"order": 1,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "*test*",
"filter_path": "",
"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)
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Template 3",
"order": 1,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "",
"filter_path": "*/test/*",
},
),
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)
def test_api_create_consumption_template_with_mailrule(self):
"""
GIVEN:
- API request to create a consumption template with a mail rule but no MailFetch source
WHEN:
- API is called
THEN:
- New template is created with MailFetch as source
"""
account1 = MailAccount.objects.create(
name="Email1",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
)
rule1 = MailRule.objects.create(
name="Rule1",
account=account1,
folder="INBOX",
filter_from="from@example.com",
filter_to="someone@somewhere.com",
filter_subject="subject",
filter_body="body",
filter_attachment_filename_include="file.pdf",
maximum_age=30,
action=MailRule.MailAction.MARK_READ,
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
order=0,
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
)
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Template 2",
"order": 1,
"sources": [DocumentSource.ApiUpload],
"filter_mailrule": rule1.pk,
},
),
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__()])

View File

@@ -0,0 +1,435 @@
import json
from django.contrib.auth.models import Group
from django.contrib.auth.models import User
from rest_framework import status
from rest_framework.test import APITestCase
from documents.data_models import DocumentSource
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 TestApiWorkflows(DirectoriesMixin, APITestCase):
ENDPOINT = "/api/workflows/"
ENDPOINT_TRIGGERS = "/api/workflow_triggers/"
ENDPOINT_ACTIONS = "/api/workflow_actions/"
def setUp(self) -> None:
super().setUp()
user = User.objects.create_superuser(username="temp_admin")
self.client.force_authenticate(user=user)
self.user2 = User.objects.create(username="user2")
self.user3 = User.objects.create(username="user3")
self.group1 = Group.objects.create(name="group1")
self.c = Correspondent.objects.create(name="Correspondent Name")
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
self.dt = DocumentType.objects.create(name="DocType Name")
self.dt2 = DocumentType.objects.create(name="DocType Name 2")
self.t1 = Tag.objects.create(name="t1")
self.t2 = Tag.objects.create(name="t2")
self.t3 = Tag.objects.create(name="t3")
self.sp = StoragePath.objects.create(name="Storage Path 1", path="/test/")
self.sp2 = StoragePath.objects.create(name="Storage Path 2", path="/test2/")
self.cf1 = CustomField.objects.create(name="Custom Field 1", data_type="string")
self.cf2 = CustomField.objects.create(
name="Custom Field 2",
data_type="integer",
)
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.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()
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 workflows
WHEN:
- API is called
THEN:
- 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_workflow = response.data["results"][0]
self.assertEqual(resp_workflow["id"], self.workflow.id)
self.assertEqual(
resp_workflow["actions"][0]["assign_correspondent"],
self.action.assign_correspondent.pk,
)
def test_api_create_workflow(self):
"""
GIVEN:
- API request to create a workflow, trigger and action separately
WHEN:
- API is called
THEN:
- Correct HTTP response
- 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": "Workflow 2",
"order": 1,
"triggers": [
{
"id": trigger_response.data["id"],
"sources": [DocumentSource.ApiUpload],
"type": trigger_response.data["type"],
"filter_filename": trigger_response.data["filter_filename"],
},
],
"actions": [
{
"id": action_response.data["id"],
"assign_title": action_response.data["assign_title"],
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Workflow.objects.count(), 2)
def test_api_create_workflow_nested(self):
"""
GIVEN:
- API request to create a workflow with nested trigger and action
WHEN:
- API is called
THEN:
- Correct HTTP response
- New workflow, trigger and action are created
"""
response = self.client.post(
self.ENDPOINT,
json.dumps(
{
"name": "Workflow 2",
"order": 1,
"triggers": [
{
"sources": [DocumentSource.ApiUpload],
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"filter_filename": "*",
"filter_path": "*/samples/*",
"filter_has_tags": [self.t1.id],
"filter_has_document_type": self.dt.id,
"filter_has_correspondent": self.c.id,
},
],
"actions": [
{
"assign_title": "Action Title",
"assign_tags": [self.t2.id],
"assign_document_type": self.dt2.id,
"assign_correspondent": self.c2.id,
"assign_storage_path": self.sp2.id,
"assign_owner": self.user2.id,
"assign_view_users": [self.user2.id],
"assign_view_groups": [self.group1.id],
"assign_change_users": [self.user2.id],
"assign_change_groups": [self.group1.id],
"assign_custom_fields": [self.cf2.id],
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Workflow.objects.count(), 2)
def test_api_create_invalid_workflow_trigger(self):
"""
GIVEN:
- 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 objects are created
"""
response = self.client.post(
self.ENDPOINT_TRIGGERS,
json.dumps(
{
"sources": [DocumentSource.ApiUpload],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
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 workflow trigger and action
- Path or filename filter or assign title are empty string
WHEN:
- API is called
THEN:
- Template is created but filter or title assignment is not set if ""
"""
response = self.client.post(
self.ENDPOINT_TRIGGERS,
json.dumps(
{
"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)
action = WorkflowAction.objects.get(id=response.data["id"])
self.assertIsNone(action.assign_title)
response = self.client.post(
self.ENDPOINT_TRIGGERS,
json.dumps(
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
"filter_filename": "",
"filter_path": "*/test/*",
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
trigger2 = WorkflowTrigger.objects.get(id=response.data["id"])
self.assertEqual(trigger2.filter_path, "*/test/*")
self.assertIsNone(trigger2.filter_filename)
def test_api_create_workflow_trigger_with_mailrule(self):
"""
GIVEN:
- API request to create a workflow trigger with a mail rule but no MailFetch source
WHEN:
- API is called
THEN:
- New trigger is created with MailFetch as source
"""
account1 = MailAccount.objects.create(
name="Email1",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
)
rule1 = MailRule.objects.create(
name="Rule1",
account=account1,
folder="INBOX",
filter_from="from@example.com",
filter_to="someone@somewhere.com",
filter_subject="subject",
filter_body="body",
filter_attachment_filename_include="file.pdf",
maximum_age=30,
action=MailRule.MailAction.MARK_READ,
assign_title_from=MailRule.TitleSource.FROM_SUBJECT,
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
order=0,
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
)
response = self.client.post(
self.ENDPOINT_TRIGGERS,
json.dumps(
{
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
"sources": [DocumentSource.ApiUpload],
"filter_mailrule": rule1.pk,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(WorkflowTrigger.objects.count(), 2)
trigger = WorkflowTrigger.objects.get(id=response.data["id"])
self.assertEqual(trigger.sources, [int(DocumentSource.MailFetch).__str__()])
def test_api_update_workflow_nested_triggers_actions(self):
"""
GIVEN:
- Existing workflow with trigger and action
WHEN:
- API request to update an existing workflow with nested triggers actions
THEN:
- Triggers and actions are updated
"""
response = self.client.patch(
f"{self.ENDPOINT}{self.workflow.id}/",
json.dumps(
{
"name": "Workflow Updated",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
"filter_has_tags": [self.t1.id],
"filter_has_correspondent": self.c.id,
"filter_has_document_type": self.dt.id,
},
],
"actions": [
{
"assign_title": "Action New Title",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
workflow = Workflow.objects.get(id=response.data["id"])
self.assertEqual(workflow.name, "Workflow Updated")
self.assertEqual(workflow.triggers.first().filter_has_tags.first(), self.t1)
self.assertEqual(workflow.actions.first().assign_title, "Action New Title")
def test_api_auto_remove_orphaned_triggers_actions(self):
"""
GIVEN:
- Existing trigger and action
WHEN:
- API request is made which creates new trigger / actions
THEN:
- "Orphaned" triggers and actions are removed
"""
response = self.client.patch(
f"{self.ENDPOINT}{self.workflow.id}/",
json.dumps(
{
"name": "Workflow Updated",
"order": 1,
"triggers": [
{
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
"filter_has_tags": [self.t1.id],
"filter_has_correspondent": self.c.id,
"filter_has_document_type": self.dt.id,
},
],
"actions": [
{
"assign_title": "Action New Title",
},
],
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
workflow = Workflow.objects.get(id=response.data["id"])
self.assertEqual(WorkflowTrigger.objects.all().count(), 1)
self.assertNotEqual(workflow.triggers.first().id, self.trigger.id)
self.assertEqual(WorkflowAction.objects.all().count(), 1)
self.assertNotEqual(workflow.actions.first().id, self.action.id)

View File

@@ -1,539 +0,0 @@
from pathlib import Path
from unittest import TestCase
from unittest import mock
import pytest
from django.contrib.auth.models import Group
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.tests.utils import DirectoriesMixin
from documents.tests.utils import FileSystemAssertsMixin
from paperless_mail.models import MailAccount
from paperless_mail.models import MailRule
@pytest.mark.django_db
class TestConsumptionTemplates(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
SAMPLE_DIR = Path(__file__).parent / "samples"
def setUp(self) -> None:
self.c = Correspondent.objects.create(name="Correspondent Name")
self.c2 = Correspondent.objects.create(name="Correspondent Name 2")
self.dt = DocumentType.objects.create(name="DocType Name")
self.t1 = Tag.objects.create(name="t1")
self.t2 = Tag.objects.create(name="t2")
self.t3 = Tag.objects.create(name="t3")
self.sp = StoragePath.objects.create(path="/test/")
self.cf1 = CustomField.objects.create(name="Custom Field 1", data_type="string")
self.cf2 = CustomField.objects.create(
name="Custom Field 2",
data_type="integer",
)
self.user2 = User.objects.create(username="user2")
self.user3 = User.objects.create(username="user3")
self.group1 = Group.objects.create(name="group1")
account1 = MailAccount.objects.create(
name="Email1",
username="username1",
password="password1",
imap_server="server.example.com",
imap_port=443,
imap_security=MailAccount.ImapSecurity.SSL,
character_set="UTF-8",
)
self.rule1 = MailRule.objects.create(
name="Rule1",
account=account1,
folder="INBOX",
filter_from="from@example.com",
filter_to="someone@somewhere.com",
filter_subject="subject",
filter_body="body",
filter_attachment_filename_include="file.pdf",
maximum_age=30,
action=MailRule.MailAction.MARK_READ,
assign_title_from=MailRule.TitleSource.NONE,
assign_correspondent_from=MailRule.CorrespondentSource.FROM_NOTHING,
order=0,
attachment_type=MailRule.AttachmentProcessing.ATTACHMENTS_ONLY,
assign_owner_from_rule=False,
)
return super().setUp()
@mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_match(self, m):
"""
GIVEN:
- Existing consumption template
WHEN:
- File that matches is consumed
THEN:
- Template overrides are applied
"""
ct = ConsumptionTemplate.objects.create(
name="Template 1",
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*simple*",
filter_path="*/samples/*",
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()
self.assertEqual(ct.__str__(), "Template 1")
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"):
with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
self.assertEqual(
overrides["override_tag_ids"],
[self.t1.pk, self.t2.pk, self.t3.pk],
)
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
self.assertEqual(overrides["override_owner_id"], self.user2.pk)
self.assertEqual(overrides["override_view_users"], [self.user3.pk])
self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
self.assertEqual(overrides["override_change_users"], [self.user3.pk])
self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
self.assertEqual(
overrides["override_title"],
"Doc from {correspondent}",
)
self.assertEqual(
overrides["override_custom_field_ids"],
[self.cf1.pk, self.cf2.pk],
)
info = cm.output[0]
expected_str = f"Document matched template {ct}"
self.assertIn(expected_str, info)
@mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_match_mailrule(self, m):
"""
GIVEN:
- Existing consumption template
WHEN:
- File that matches is consumed via mail rule
THEN:
- Template overrides are applied
"""
ct = ConsumptionTemplate.objects.create(
name="Template 1",
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_mailrule=self.rule1,
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()
self.assertEqual(ct.__str__(), "Template 1")
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"):
with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
mailrule_id=self.rule1.pk,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
self.assertEqual(
overrides["override_tag_ids"],
[self.t1.pk, self.t2.pk, self.t3.pk],
)
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
self.assertEqual(overrides["override_owner_id"], self.user2.pk)
self.assertEqual(overrides["override_view_users"], [self.user3.pk])
self.assertEqual(overrides["override_view_groups"], [self.group1.pk])
self.assertEqual(overrides["override_change_users"], [self.user3.pk])
self.assertEqual(overrides["override_change_groups"], [self.group1.pk])
self.assertEqual(
overrides["override_title"],
"Doc from {correspondent}",
)
info = cm.output[0]
expected_str = f"Document matched template {ct}"
self.assertIn(expected_str, info)
@mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_match_multiple(self, m):
"""
GIVEN:
- Multiple existing consumption template
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,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_path="*/samples/*",
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",
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*simple*",
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()
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"):
with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
# template 1
self.assertEqual(overrides["override_correspondent_id"], self.c.pk)
self.assertEqual(overrides["override_document_type_id"], self.dt.pk)
# template 2
self.assertEqual(overrides["override_storage_path_id"], self.sp.pk)
# template 1 & 2
self.assertEqual(
overrides["override_tag_ids"],
[self.t1.pk, self.t2.pk, self.t3.pk],
)
self.assertEqual(
overrides["override_view_users"],
[self.user2.pk, self.user3.pk],
)
expected_str = f"Document matched template {ct1}"
self.assertIn(expected_str, cm.output[0])
expected_str = f"Document matched template {ct2}"
self.assertIn(expected_str, cm.output[1])
@mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_no_match_filename(self, m):
"""
GIVEN:
- Existing consumption template
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,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*foobar*",
filter_path=None,
assign_title="Doc from {correspondent}",
assign_correspondent=self.c,
assign_document_type=self.dt,
assign_storage_path=self.sp,
assign_owner=self.user2,
)
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"):
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertIsNone(overrides["override_correspondent_id"])
self.assertIsNone(overrides["override_document_type_id"])
self.assertIsNone(overrides["override_tag_ids"])
self.assertIsNone(overrides["override_storage_path_id"])
self.assertIsNone(overrides["override_owner_id"])
self.assertIsNone(overrides["override_view_users"])
self.assertIsNone(overrides["override_view_groups"])
self.assertIsNone(overrides["override_change_users"])
self.assertIsNone(overrides["override_change_groups"])
self.assertIsNone(overrides["override_title"])
expected_str = f"Document did not match template {ct}"
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):
"""
GIVEN:
- Existing consumption template
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,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_path="*foo/bar*",
assign_title="Doc from {correspondent}",
assign_correspondent=self.c,
assign_document_type=self.dt,
assign_storage_path=self.sp,
assign_owner=self.user2,
)
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"):
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertIsNone(overrides["override_correspondent_id"])
self.assertIsNone(overrides["override_document_type_id"])
self.assertIsNone(overrides["override_tag_ids"])
self.assertIsNone(overrides["override_storage_path_id"])
self.assertIsNone(overrides["override_owner_id"])
self.assertIsNone(overrides["override_view_users"])
self.assertIsNone(overrides["override_view_groups"])
self.assertIsNone(overrides["override_change_users"])
self.assertIsNone(overrides["override_change_groups"])
self.assertIsNone(overrides["override_title"])
expected_str = f"Document did not match template {ct}"
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):
"""
GIVEN:
- Existing consumption template
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,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_mailrule=self.rule1,
assign_title="Doc from {correspondent}",
assign_correspondent=self.c,
assign_document_type=self.dt,
assign_storage_path=self.sp,
assign_owner=self.user2,
)
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"):
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
mailrule_id=99,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertIsNone(overrides["override_correspondent_id"])
self.assertIsNone(overrides["override_document_type_id"])
self.assertIsNone(overrides["override_tag_ids"])
self.assertIsNone(overrides["override_storage_path_id"])
self.assertIsNone(overrides["override_owner_id"])
self.assertIsNone(overrides["override_view_users"])
self.assertIsNone(overrides["override_view_groups"])
self.assertIsNone(overrides["override_change_users"])
self.assertIsNone(overrides["override_change_groups"])
self.assertIsNone(overrides["override_title"])
expected_str = f"Document did not match template {ct}"
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):
"""
GIVEN:
- Existing consumption template
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,
sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_path="*",
assign_title="Doc from {correspondent}",
assign_correspondent=self.c,
assign_document_type=self.dt,
assign_storage_path=self.sp,
assign_owner=self.user2,
)
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"):
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ApiUpload,
original_file=test_file,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertIsNone(overrides["override_correspondent_id"])
self.assertIsNone(overrides["override_document_type_id"])
self.assertIsNone(overrides["override_tag_ids"])
self.assertIsNone(overrides["override_storage_path_id"])
self.assertIsNone(overrides["override_owner_id"])
self.assertIsNone(overrides["override_view_users"])
self.assertIsNone(overrides["override_view_groups"])
self.assertIsNone(overrides["override_change_users"])
self.assertIsNone(overrides["override_change_groups"])
self.assertIsNone(overrides["override_title"])
expected_str = f"Document did not match template {ct}"
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])
@mock.patch("documents.consumer.Consumer.try_consume_file")
def test_consumption_template_repeat_custom_fields(self, m):
"""
GIVEN:
- Existing consumption templates which assign the same custom field
WHEN:
- File that matches is consumed
THEN:
- Custom field is added the first time successfully
"""
ct = ConsumptionTemplate.objects.create(
name="Template 1",
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*simple*",
)
ct.assign_custom_fields.add(self.cf1.pk)
ct.save()
ct2 = ConsumptionTemplate.objects.create(
name="Template 2",
order=1,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*simple*",
)
ct2.assign_custom_fields.add(self.cf1.pk)
ct2.save()
test_file = self.SAMPLE_DIR / "simple.pdf"
with mock.patch("documents.tasks.async_to_sync"):
with self.assertLogs("paperless.matching", level="INFO") as cm:
tasks.consume_file(
ConsumableDocument(
source=DocumentSource.ConsumeFolder,
original_file=test_file,
),
None,
)
m.assert_called_once()
_, overrides = m.call_args
self.assertEqual(
overrides["override_custom_field_ids"],
[self.cf1.pk],
)
expected_str = f"Document matched template {ct}"
self.assertIn(expected_str, cm.output[0])
expected_str = f"Document matched template {ct2}"
self.assertIn(expected_str, cm.output[1])

View File

@@ -21,7 +21,6 @@ from guardian.models import UserObjectPermission
from guardian.shortcuts import assign_perm
from documents.management.commands import document_exporter
from documents.models import ConsumptionTemplate
from documents.models import Correspondent
from documents.models import CustomField
from documents.models import CustomFieldInstance
@@ -31,6 +30,9 @@ from documents.models import Note
from documents.models import StoragePath
from documents.models import Tag
from documents.models import User
from documents.models import Workflow
from documents.models import WorkflowAction
from documents.models import WorkflowTrigger
from documents.sanity_checker import check_sanity
from documents.settings import EXPORTER_FILE_NAME
from documents.tests.utils import DirectoriesMixin
@@ -109,7 +111,16 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.d4.storage_path = self.sp1
self.d4.save()
self.ct1 = ConsumptionTemplate.objects.create(name="CT 1", filter_path="*")
self.trigger = WorkflowTrigger.objects.create(
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
sources=[1],
filter_filename="*",
)
self.action = WorkflowAction.objects.create(assign_title="new title")
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()
super().setUp()
@@ -168,7 +179,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
manifest = self._do_export(use_filename_format=use_filename_format)
self.assertEqual(len(manifest), 178)
self.assertEqual(len(manifest), 190)
# dont include consumer or AnonymousUser users
self.assertEqual(
@@ -262,7 +273,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
self.assertEqual(Document.objects.get(id=self.d4.id).title, "wow_dec")
self.assertEqual(GroupObjectPermission.objects.count(), 1)
self.assertEqual(UserObjectPermission.objects.count(), 1)
self.assertEqual(Permission.objects.count(), 128)
self.assertEqual(Permission.objects.count(), 136)
messages = check_sanity()
# everything is alright after the test
self.assertEqual(len(messages), 0)
@@ -694,15 +705,15 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
os.path.join(self.dirs.media_dir, "documents"),
)
self.assertEqual(ContentType.objects.count(), 32)
self.assertEqual(Permission.objects.count(), 128)
self.assertEqual(ContentType.objects.count(), 34)
self.assertEqual(Permission.objects.count(), 136)
manifest = self._do_export()
with paperless_environment():
self.assertEqual(
len(list(filter(lambda e: e["model"] == "auth.permission", manifest))),
128,
136,
)
# add 1 more to db to show objects are not re-created by import
Permission.objects.create(
@@ -710,7 +721,7 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
codename="test_perm",
content_type_id=1,
)
self.assertEqual(Permission.objects.count(), 129)
self.assertEqual(Permission.objects.count(), 137)
# will cause an import error
self.user.delete()
@@ -719,5 +730,5 @@ class TestExportImport(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
with self.assertRaises(IntegrityError):
call_command("document_importer", "--no-progress-bar", self.target)
self.assertEqual(ContentType.objects.count(), 32)
self.assertEqual(Permission.objects.count(), 129)
self.assertEqual(ContentType.objects.count(), 34)
self.assertEqual(Permission.objects.count(), 137)

View File

@@ -33,11 +33,18 @@ class TestReverseMigrateConsumptionTemplate(TestMigrations):
self.Permission = apps.get_model("auth", "Permission")
self.user = User.objects.create(username="user1")
self.group = Group.objects.create(name="group1")
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
self.user.user_permissions.add(permission.id)
self.group.permissions.add(permission.id)
permission = self.Permission.objects.filter(
codename="add_consumptiontemplate",
).first()
if permission is not None:
self.user.user_permissions.add(permission.id)
self.group.permissions.add(permission.id)
def test_remove_consumptiontemplate_permissions(self):
permission = self.Permission.objects.get(codename="add_consumptiontemplate")
self.assertFalse(self.user.has_perm(f"documents.{permission.codename}"))
self.assertFalse(permission in self.group.permissions.all())
permission = self.Permission.objects.filter(
codename="add_consumptiontemplate",
).first()
# can be None ? now that CTs removed
if permission is not None:
self.assertFalse(self.user.has_perm(f"documents.{permission.codename}"))
self.assertFalse(permission in self.group.permissions.all())

View File

@@ -0,0 +1,131 @@
from documents.data_models import DocumentSource
from documents.tests.utils import TestMigrations
class TestMigrateWorkflow(TestMigrations):
migrate_from = "1043_alter_savedviewfilterrule_rule_type"
migrate_to = "1044_workflow_workflowaction_workflowtrigger_and_more"
dependencies = (
("paperless_mail", "0023_remove_mailrule_filter_attachment_filename_and_more"),
)
def setUpBeforeMigration(self, apps):
User = apps.get_model("auth", "User")
Group = apps.get_model("auth", "Group")
self.Permission = apps.get_model("auth", "Permission")
self.user = User.objects.create(username="user1")
self.group = Group.objects.create(name="group1")
permission = self.Permission.objects.get(codename="add_document")
self.user.user_permissions.add(permission.id)
self.group.permissions.add(permission.id)
# create a CT to migrate
c = apps.get_model("documents", "Correspondent").objects.create(
name="Correspondent Name",
)
dt = apps.get_model("documents", "DocumentType").objects.create(
name="DocType Name",
)
t1 = apps.get_model("documents", "Tag").objects.create(name="t1")
sp = apps.get_model("documents", "StoragePath").objects.create(path="/test/")
cf1 = apps.get_model("documents", "CustomField").objects.create(
name="Custom Field 1",
data_type="string",
)
ma = apps.get_model("paperless_mail", "MailAccount").objects.create(
name="MailAccount 1",
)
mr = apps.get_model("paperless_mail", "MailRule").objects.create(
name="MailRule 1",
order=0,
account=ma,
)
user2 = User.objects.create(username="user2")
user3 = User.objects.create(username="user3")
group2 = Group.objects.create(name="group2")
ConsumptionTemplate = apps.get_model("documents", "ConsumptionTemplate")
ct = ConsumptionTemplate.objects.create(
name="Template 1",
order=0,
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
filter_filename="*simple*",
filter_path="*/samples/*",
filter_mailrule=mr,
assign_title="Doc from {correspondent}",
assign_correspondent=c,
assign_document_type=dt,
assign_storage_path=sp,
assign_owner=user2,
)
ct.assign_tags.add(t1)
ct.assign_view_users.add(user3)
ct.assign_view_groups.add(group2)
ct.assign_change_users.add(user3)
ct.assign_change_groups.add(group2)
ct.assign_custom_fields.add(cf1)
ct.save()
def test_users_with_add_documents_get_add_and_workflow_templates_get_migrated(self):
permission = self.Permission.objects.get(codename="add_workflow")
self.assertTrue(permission in self.user.user_permissions.all())
self.assertTrue(permission in self.group.permissions.all())
Workflow = self.apps.get_model("documents", "Workflow")
self.assertEqual(Workflow.objects.all().count(), 1)
class TestReverseMigrateWorkflow(TestMigrations):
migrate_from = "1044_workflow_workflowaction_workflowtrigger_and_more"
migrate_to = "1043_alter_savedviewfilterrule_rule_type"
def setUpBeforeMigration(self, apps):
User = apps.get_model("auth", "User")
Group = apps.get_model("auth", "Group")
self.Permission = apps.get_model("auth", "Permission")
self.user = User.objects.create(username="user1")
self.group = Group.objects.create(name="group1")
permission = self.Permission.objects.filter(
codename="add_workflow",
).first()
if permission is not None:
self.user.user_permissions.add(permission.id)
self.group.permissions.add(permission.id)
Workflow = apps.get_model("documents", "Workflow")
WorkflowTrigger = apps.get_model("documents", "WorkflowTrigger")
WorkflowAction = apps.get_model("documents", "WorkflowAction")
trigger = WorkflowTrigger.objects.create(
type=0,
sources=[DocumentSource.ConsumeFolder],
filter_path="*/path/*",
filter_filename="*file*",
)
action = WorkflowAction.objects.create(
assign_title="assign title",
)
workflow = Workflow.objects.create(
name="workflow 1",
order=0,
)
workflow.triggers.set([trigger])
workflow.actions.set([action])
workflow.save()
def test_remove_workflow_permissions_and_migrate_workflows_to_consumption_templates(
self,
):
permission = self.Permission.objects.filter(
codename="add_workflow",
).first()
if permission is not None:
self.assertFalse(permission in self.user.user_permissions.all())
self.assertFalse(permission in self.group.permissions.all())
ConsumptionTemplate = self.apps.get_model("documents", "ConsumptionTemplate")
self.assertEqual(ConsumptionTemplate.objects.all().count(), 1)

File diff suppressed because it is too large Load Diff

View File

@@ -265,6 +265,7 @@ class TestMigrations(TransactionTestCase):
return apps.get_containing_app_config(type(self).__module__).name
migrate_from = None
dependencies = None
migrate_to = None
auto_migrate = True
@@ -277,6 +278,8 @@ class TestMigrations(TransactionTestCase):
type(self).__name__,
)
self.migrate_from = [(self.app, self.migrate_from)]
if self.dependencies is not None:
self.migrate_from.extend(self.dependencies)
self.migrate_to = [(self.app, self.migrate_to)]
executor = MigrationExecutor(connection)
old_apps = executor.loader.project_state(self.migrate_from).apps

View File

@@ -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,10 @@ 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.signals import document_updated
from documents.tasks import consume_file
from paperless import version
from paperless.db import GnuPG
@@ -320,6 +325,12 @@ class DocumentViewSet(
from documents import index
index.add_or_update_document(self.get_object())
document_updated.send(
sender=self.__class__,
document=self.get_object(),
)
return response
def destroy(self, request, *args, **kwargs):
@@ -1373,25 +1384,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",
)
)

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr ""
"Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-12-09 10:53-0800\n"
"POT-Creation-Date: 2024-01-01 07:54-0800\n"
"PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n"
"Language-Team: English\n"
@@ -25,27 +25,27 @@ msgstr ""
msgid "owner"
msgstr ""
#: documents/models.py:53
#: documents/models.py:53 documents/models.py:894
msgid "None"
msgstr ""
#: documents/models.py:54
#: documents/models.py:54 documents/models.py:895
msgid "Any word"
msgstr ""
#: documents/models.py:55
#: documents/models.py:55 documents/models.py:896
msgid "All words"
msgstr ""
#: documents/models.py:56
#: documents/models.py:56 documents/models.py:897
msgid "Exact match"
msgstr ""
#: documents/models.py:57
#: documents/models.py:57 documents/models.py:898
msgid "Regular expression"
msgstr ""
#: documents/models.py:58
#: documents/models.py:58 documents/models.py:899
msgid "Fuzzy word"
msgstr ""
@@ -53,20 +53,20 @@ msgstr ""
msgid "Automatic"
msgstr ""
#: documents/models.py:62 documents/models.py:402 documents/models.py:897
#: documents/models.py:62 documents/models.py:402 documents/models.py:1099
#: paperless_mail/models.py:18 paperless_mail/models.py:93
msgid "name"
msgstr ""
#: documents/models.py:64
#: documents/models.py:64 documents/models.py:955
msgid "match"
msgstr ""
#: documents/models.py:67
#: documents/models.py:67 documents/models.py:958
msgid "matching algorithm"
msgstr ""
#: documents/models.py:72
#: documents/models.py:72 documents/models.py:963
msgid "is insensitive"
msgstr ""
@@ -615,118 +615,174 @@ msgstr ""
msgid "custom field instances"
msgstr ""
#: documents/models.py:893
#: documents/models.py:902
msgid "Consumption Started"
msgstr ""
#: documents/models.py:903
msgid "Document Added"
msgstr ""
#: documents/models.py:904
msgid "Document Updated"
msgstr ""
#: documents/models.py:907
msgid "Consume Folder"
msgstr ""
#: documents/models.py:894
#: documents/models.py:908
msgid "Api Upload"
msgstr ""
#: documents/models.py:895
#: documents/models.py:909
msgid "Mail Fetch"
msgstr ""
#: documents/models.py:899 paperless_mail/models.py:95
msgid "order"
#: documents/models.py:912
msgid "Workflow Trigger Type"
msgstr ""
#: documents/models.py:908
#: documents/models.py:924
msgid "filter path"
msgstr ""
#: documents/models.py:913
#: documents/models.py:929
msgid ""
"Only consume documents with a path that matches this if specified. Wildcards "
"specified as * are allowed. Case insensitive."
msgstr ""
#: documents/models.py:920
#: documents/models.py:936
msgid "filter filename"
msgstr ""
#: documents/models.py:925 paperless_mail/models.py:148
#: documents/models.py:941 paperless_mail/models.py:148
msgid ""
"Only consume documents which entirely match this filename if specified. "
"Wildcards such as *.pdf or *invoice* are allowed. Case insensitive."
msgstr ""
#: documents/models.py:936
#: documents/models.py:952
msgid "filter documents from this mail rule"
msgstr ""
#: documents/models.py:940
#: documents/models.py:968
msgid "has these tag(s)"
msgstr ""
#: documents/models.py:976
msgid "has this document type"
msgstr ""
#: documents/models.py:984
msgid "has this correspondent"
msgstr ""
#: documents/models.py:988
msgid "workflow trigger"
msgstr ""
#: documents/models.py:989
msgid "workflow triggers"
msgstr ""
#: documents/models.py:997
msgid "Assignment"
msgstr ""
#: documents/models.py:1000
msgid "Workflow Action Type"
msgstr ""
#: documents/models.py:1006
msgid "assign title"
msgstr ""
#: documents/models.py:945
#: documents/models.py:1011
msgid ""
"Assign a document title, can include some placeholders, see documentation."
msgstr ""
#: documents/models.py:953 paperless_mail/models.py:216
#: documents/models.py:1019 paperless_mail/models.py:216
msgid "assign this tag"
msgstr ""
#: documents/models.py:961 paperless_mail/models.py:224
#: documents/models.py:1027 paperless_mail/models.py:224
msgid "assign this document type"
msgstr ""
#: documents/models.py:969 paperless_mail/models.py:238
#: documents/models.py:1035 paperless_mail/models.py:238
msgid "assign this correspondent"
msgstr ""
#: documents/models.py:977
#: documents/models.py:1043
msgid "assign this storage path"
msgstr ""
#: documents/models.py:986
#: documents/models.py:1052
msgid "assign this owner"
msgstr ""
#: documents/models.py:993
#: documents/models.py:1059
msgid "grant view permissions to these users"
msgstr ""
#: documents/models.py:1000
#: documents/models.py:1066
msgid "grant view permissions to these groups"
msgstr ""
#: documents/models.py:1007
#: documents/models.py:1073
msgid "grant change permissions to these users"
msgstr ""
#: documents/models.py:1014
#: documents/models.py:1080
msgid "grant change permissions to these groups"
msgstr ""
#: documents/models.py:1021
#: documents/models.py:1087
msgid "assign these custom fields"
msgstr ""
#: documents/models.py:1025
msgid "consumption template"
#: documents/models.py:1091
msgid "workflow action"
msgstr ""
#: documents/models.py:1026
msgid "consumption templates"
#: documents/models.py:1092
msgid "workflow actions"
msgstr ""
#: documents/serialisers.py:105
#: documents/models.py:1101 paperless_mail/models.py:95
msgid "order"
msgstr ""
#: documents/models.py:1107
msgid "triggers"
msgstr ""
#: documents/models.py:1114
msgid "actions"
msgstr ""
#: documents/models.py:1117
msgid "enabled"
msgstr ""
#: documents/serialisers.py:111
#, python-format
msgid "Invalid regular expression: %(error)s"
msgstr ""
#: documents/serialisers.py:399
#: documents/serialisers.py:405
msgid "Invalid color."
msgstr ""
#: documents/serialisers.py:865
#: documents/serialisers.py:988
#, python-format
msgid "File type %(type)s not supported"
msgstr ""
#: documents/serialisers.py:962
#: documents/serialisers.py:1085
msgid "Invalid variable detected."
msgstr ""
@@ -869,135 +925,286 @@ msgstr ""
msgid "Send me instructions!"
msgstr ""
#: documents/validators.py:17
#, python-brace-format
msgid "Unable to parse URI {value}, missing scheme"
msgstr ""
#: documents/validators.py:22
#, python-brace-format
msgid "Unable to parse URI {value}, missing net location or path"
msgstr ""
#: documents/validators.py:27
#, python-brace-format
msgid "Unable to parse URI {value}"
msgstr ""
#: paperless/apps.py:10
msgid "Paperless"
msgstr ""
#: paperless/settings.py:586
msgid "English (US)"
#: paperless/models.py:25
msgid "pdf"
msgstr ""
#: paperless/settings.py:587
msgid "Arabic"
#: paperless/models.py:26
msgid "pdfa"
msgstr ""
#: paperless/settings.py:588
msgid "Afrikaans"
#: paperless/models.py:27
msgid "pdfa-1"
msgstr ""
#: paperless/settings.py:589
msgid "Belarusian"
#: paperless/models.py:28
msgid "pdfa-2"
msgstr ""
#: paperless/settings.py:590
msgid "Bulgarian"
#: paperless/models.py:29
msgid "pdfa-3"
msgstr ""
#: paperless/settings.py:591
msgid "Catalan"
#: paperless/models.py:38
msgid "skip"
msgstr ""
#: paperless/settings.py:592
msgid "Czech"
#: paperless/models.py:39
msgid "redo"
msgstr ""
#: paperless/settings.py:593
msgid "Danish"
#: paperless/models.py:40
msgid "force"
msgstr ""
#: paperless/settings.py:594
msgid "German"
#: paperless/models.py:41
msgid "skip_noarchive"
msgstr ""
#: paperless/settings.py:595
msgid "Greek"
#: paperless/models.py:49
msgid "never"
msgstr ""
#: paperless/settings.py:596
msgid "English (GB)"
#: paperless/models.py:50
msgid "with_text"
msgstr ""
#: paperless/settings.py:597
msgid "Spanish"
#: paperless/models.py:51
msgid "always"
msgstr ""
#: paperless/settings.py:598
msgid "Finnish"
#: paperless/models.py:59
msgid "clean"
msgstr ""
#: paperless/settings.py:599
msgid "French"
#: paperless/models.py:60
msgid "clean-final"
msgstr ""
#: paperless/settings.py:600
msgid "Hungarian"
#: paperless/models.py:61
msgid "none"
msgstr ""
#: paperless/models.py:69
msgid "LeaveColorUnchanged"
msgstr ""
#: paperless/models.py:70
msgid "RGB"
msgstr ""
#: paperless/models.py:71
msgid "UseDeviceIndependentColor"
msgstr ""
#: paperless/models.py:72
msgid "Gray"
msgstr ""
#: paperless/models.py:73
msgid "CMYK"
msgstr ""
#: paperless/models.py:82
msgid "Sets the output PDF type"
msgstr ""
#: paperless/models.py:94
msgid "Do OCR from page 1 to this value"
msgstr ""
#: paperless/models.py:100
msgid "Do OCR using these languages"
msgstr ""
#: paperless/models.py:107
msgid "Sets the OCR mode"
msgstr ""
#: paperless/models.py:115
msgid "Controls the generation of an archive file"
msgstr ""
#: paperless/models.py:123
msgid "Sets image DPI fallback value"
msgstr ""
#: paperless/models.py:130
msgid "Controls the unpaper cleaning"
msgstr ""
#: paperless/models.py:137
msgid "Enables deskew"
msgstr ""
#: paperless/models.py:140
msgid "Enables page rotation"
msgstr ""
#: paperless/models.py:145
msgid "Sets the threshold for rotation of pages"
msgstr ""
#: paperless/models.py:151
msgid "Sets the maximum image size for decompression"
msgstr ""
#: paperless/models.py:157
msgid "Sets the Ghostscript color conversion strategy"
msgstr ""
#: paperless/models.py:165
msgid "Adds additional user arguments for OCRMyPDF"
msgstr ""
#: paperless/models.py:170
msgid "paperless application settings"
msgstr ""
#: paperless/settings.py:601
msgid "Italian"
msgid "English (US)"
msgstr ""
#: paperless/settings.py:602
msgid "Luxembourgish"
msgid "Arabic"
msgstr ""
#: paperless/settings.py:603
msgid "Norwegian"
msgid "Afrikaans"
msgstr ""
#: paperless/settings.py:604
msgid "Dutch"
msgid "Belarusian"
msgstr ""
#: paperless/settings.py:605
msgid "Polish"
msgid "Bulgarian"
msgstr ""
#: paperless/settings.py:606
msgid "Portuguese (Brazil)"
msgid "Catalan"
msgstr ""
#: paperless/settings.py:607
msgid "Portuguese"
msgid "Czech"
msgstr ""
#: paperless/settings.py:608
msgid "Romanian"
msgid "Danish"
msgstr ""
#: paperless/settings.py:609
msgid "Russian"
msgid "German"
msgstr ""
#: paperless/settings.py:610
msgid "Slovak"
msgid "Greek"
msgstr ""
#: paperless/settings.py:611
msgid "Slovenian"
msgid "English (GB)"
msgstr ""
#: paperless/settings.py:612
msgid "Serbian"
msgid "Spanish"
msgstr ""
#: paperless/settings.py:613
msgid "Swedish"
msgid "Finnish"
msgstr ""
#: paperless/settings.py:614
msgid "Turkish"
msgid "French"
msgstr ""
#: paperless/settings.py:615
msgid "Ukrainian"
msgid "Hungarian"
msgstr ""
#: paperless/settings.py:616
msgid "Italian"
msgstr ""
#: paperless/settings.py:617
msgid "Luxembourgish"
msgstr ""
#: paperless/settings.py:618
msgid "Norwegian"
msgstr ""
#: paperless/settings.py:619
msgid "Dutch"
msgstr ""
#: paperless/settings.py:620
msgid "Polish"
msgstr ""
#: paperless/settings.py:621
msgid "Portuguese (Brazil)"
msgstr ""
#: paperless/settings.py:622
msgid "Portuguese"
msgstr ""
#: paperless/settings.py:623
msgid "Romanian"
msgstr ""
#: paperless/settings.py:624
msgid "Russian"
msgstr ""
#: paperless/settings.py:625
msgid "Slovak"
msgstr ""
#: paperless/settings.py:626
msgid "Slovenian"
msgstr ""
#: paperless/settings.py:627
msgid "Serbian"
msgstr ""
#: paperless/settings.py:628
msgid "Swedish"
msgstr ""
#: paperless/settings.py:629
msgid "Turkish"
msgstr ""
#: paperless/settings.py:630
msgid "Ukrainian"
msgstr ""
#: paperless/settings.py:631
msgid "Chinese Simplified"
msgstr ""
#: paperless/urls.py:194
#: paperless/urls.py:205
msgid "Paperless-ngx administration"
msgstr ""

View File

@@ -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 ApplicationConfigurationViewSet
from paperless.views import FaviconView
@@ -59,7 +61,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)
api_router.register(r"config", ApplicationConfigurationViewSet)