mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-07-28 18:24:38 -05:00
Feature: Workflows (#5121)
This commit is contained in:
@@ -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)
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -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(
|
||||
|
@@ -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
|
||||
|
@@ -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,
|
||||
),
|
||||
]
|
@@ -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}"
|
||||
|
@@ -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
|
||||
|
@@ -3,3 +3,4 @@ from django.dispatch import Signal
|
||||
document_consumption_started = Signal()
|
||||
document_consumption_finished = Signal()
|
||||
document_consumer_declaration = Signal()
|
||||
document_updated = Signal()
|
||||
|
@@ -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):
|
||||
"""
|
||||
|
@@ -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:
|
||||
|
@@ -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__()])
|
435
src/documents/tests/test_api_workflows.py
Normal file
435
src/documents/tests/test_api_workflows.py
Normal 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)
|
@@ -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])
|
@@ -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)
|
||||
|
@@ -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())
|
||||
|
131
src/documents/tests/test_migration_workflows.py
Normal file
131
src/documents/tests/test_migration_workflows.py
Normal 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)
|
1017
src/documents/tests/test_workflows.py
Normal file
1017
src/documents/tests/test_workflows.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
@@ -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",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
|
@@ -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 ""
|
||||
|
||||
|
@@ -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)
|
||||
|
||||
|
Reference in New Issue
Block a user