Initial implementation of document added trigger
This commit is contained in:
parent
b4023d3aae
commit
84199a6894
@ -11,6 +11,7 @@ class DocumentsConfig(AppConfig):
|
|||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
from documents.signals.handlers import add_inbox_tags
|
from documents.signals.handlers import add_inbox_tags
|
||||||
from documents.signals.handlers import add_to_index
|
from documents.signals.handlers import add_to_index
|
||||||
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.signals.handlers import set_correspondent
|
from documents.signals.handlers import set_correspondent
|
||||||
from documents.signals.handlers import set_document_type
|
from documents.signals.handlers import set_document_type
|
||||||
from documents.signals.handlers import set_log_entry
|
from documents.signals.handlers import set_log_entry
|
||||||
@ -24,5 +25,6 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
document_consumption_finished.connect(set_log_entry)
|
document_consumption_finished.connect(set_log_entry)
|
||||||
document_consumption_finished.connect(add_to_index)
|
document_consumption_finished.connect(add_to_index)
|
||||||
|
document_consumption_finished.connect(run_workflows)
|
||||||
|
|
||||||
AppConfig.ready(self)
|
AppConfig.ready(self)
|
||||||
|
@ -666,10 +666,6 @@ class Consumer(LoggingMixin):
|
|||||||
return overrides
|
return overrides
|
||||||
|
|
||||||
def _parse_title_placeholders(self, title: str) -> str:
|
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())
|
local_added = timezone.localtime(timezone.now())
|
||||||
|
|
||||||
correspondent_name = (
|
correspondent_name = (
|
||||||
@ -688,20 +684,14 @@ class Consumer(LoggingMixin):
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return title.format(
|
return parse_doc_title_w_placeholders(
|
||||||
correspondent=correspondent_name,
|
title,
|
||||||
document_type=doc_type_name,
|
correspondent_name,
|
||||||
added=local_added.isoformat(),
|
doc_type_name,
|
||||||
added_year=local_added.strftime("%Y"),
|
owner_username,
|
||||||
added_year_short=local_added.strftime("%y"),
|
local_added,
|
||||||
added_month=local_added.strftime("%m"),
|
self.filename,
|
||||||
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()
|
|
||||||
|
|
||||||
def _store(
|
def _store(
|
||||||
self,
|
self,
|
||||||
@ -854,3 +844,47 @@ class Consumer(LoggingMixin):
|
|||||||
self.log.warning("Script stderr:")
|
self.log.warning("Script stderr:")
|
||||||
for line in stderr_str:
|
for line in stderr_str:
|
||||||
self.log.warning(line)
|
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:
|
||||||
|
"""
|
||||||
|
Title placeholders for Workflows using Consumption triggers can only include
|
||||||
|
items that are assigned as part of this template (since auto-matching hasnt
|
||||||
|
happened yet)
|
||||||
|
"""
|
||||||
|
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()
|
||||||
|
@ -239,7 +239,7 @@ def _split_match(matching_model):
|
|||||||
|
|
||||||
|
|
||||||
def document_matches_workflow(
|
def document_matches_workflow(
|
||||||
document: ConsumableDocument,
|
document: ConsumableDocument | Document,
|
||||||
workflow: Workflow,
|
workflow: Workflow,
|
||||||
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
trigger_type: WorkflowTrigger.WorkflowTriggerType,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
@ -258,52 +258,74 @@ def document_matches_workflow(
|
|||||||
trigger_matched = False
|
trigger_matched = False
|
||||||
else:
|
else:
|
||||||
for trigger in triggers:
|
for trigger in triggers:
|
||||||
# Document source vs template source
|
if trigger_type is WorkflowTrigger.WorkflowTriggerType.CONSUMPTION:
|
||||||
if document.source not in [int(x) for x in list(trigger.sources)]:
|
# document is type ConsumableDocument
|
||||||
log_match_failure(
|
|
||||||
f"Document source {document.source.name} not in"
|
|
||||||
f" {[DocumentSource(int(x)).name for x in trigger.sources]}",
|
|
||||||
)
|
|
||||||
trigger_matched = False
|
|
||||||
|
|
||||||
# Document mail rule vs template mail rule
|
# Document source vs template source
|
||||||
if (
|
if document.source not in [int(x) for x in list(trigger.sources)]:
|
||||||
document.mailrule_id is not None
|
log_match_failure(
|
||||||
and trigger.filter_mailrule is not None
|
f"Document source {document.source.name} not in"
|
||||||
and document.mailrule_id != trigger.filter_mailrule.pk
|
f" {[DocumentSource(int(x)).name for x in trigger.sources]}",
|
||||||
):
|
)
|
||||||
log_match_failure(
|
trigger_matched = False
|
||||||
f"Document mail rule {document.mailrule_id}"
|
|
||||||
f" != {trigger.filter_mailrule.pk}",
|
|
||||||
)
|
|
||||||
trigger_matched = False
|
|
||||||
|
|
||||||
# Document filename vs template filename
|
# Document mail rule vs template mail rule
|
||||||
if (
|
if (
|
||||||
trigger.filter_filename is not None
|
document.mailrule_id is not None
|
||||||
and len(trigger.filter_filename) > 0
|
and trigger.filter_mailrule is not None
|
||||||
and not fnmatch(
|
and document.mailrule_id != trigger.filter_mailrule.pk
|
||||||
document.original_file.name.lower(),
|
):
|
||||||
trigger.filter_filename.lower(),
|
log_match_failure(
|
||||||
)
|
f"Document mail rule {document.mailrule_id}"
|
||||||
):
|
f" != {trigger.filter_mailrule.pk}",
|
||||||
log_match_failure(
|
)
|
||||||
f"Document filename {document.original_file.name} does not match"
|
trigger_matched = False
|
||||||
f" {trigger.filter_filename.lower()}",
|
|
||||||
)
|
|
||||||
trigger_matched = False
|
|
||||||
|
|
||||||
# Document path vs template path
|
# Document filename vs template filename
|
||||||
if (
|
if (
|
||||||
trigger.filter_path is not None
|
trigger.filter_filename is not None
|
||||||
and len(trigger.filter_path) > 0
|
and len(trigger.filter_filename) > 0
|
||||||
and not document.original_file.match(trigger.filter_path)
|
and not fnmatch(
|
||||||
):
|
document.original_file.name.lower(),
|
||||||
log_match_failure(
|
trigger.filter_filename.lower(),
|
||||||
f"Document path {document.original_file}"
|
)
|
||||||
f" does not match {trigger.filter_path}",
|
):
|
||||||
)
|
log_match_failure(
|
||||||
trigger_matched = False
|
f"Document filename {document.original_file.name} does not match"
|
||||||
|
f" {trigger.filter_filename.lower()}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
|
# Document path vs template path
|
||||||
|
if (
|
||||||
|
trigger.filter_path is not None
|
||||||
|
and len(trigger.filter_path) > 0
|
||||||
|
and not document.original_file.match(trigger.filter_path)
|
||||||
|
):
|
||||||
|
log_match_failure(
|
||||||
|
f"Document path {document.original_file}"
|
||||||
|
f" does not match {trigger.filter_path}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
|
elif trigger_type is WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED:
|
||||||
|
# document is type Document
|
||||||
|
|
||||||
|
# Document filename vs template 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(),
|
||||||
|
)
|
||||||
|
):
|
||||||
|
log_match_failure(
|
||||||
|
f"Document filename {document.original_filename} does not match"
|
||||||
|
f" {trigger.filter_filename.lower()}",
|
||||||
|
)
|
||||||
|
trigger_matched = False
|
||||||
|
|
||||||
if trigger_matched:
|
if trigger_matched:
|
||||||
logger.info(f"Document matched {trigger} from {workflow}")
|
logger.info(f"Document matched {trigger} from {workflow}")
|
||||||
|
@ -24,14 +24,19 @@ from filelock import FileLock
|
|||||||
|
|
||||||
from documents import matching
|
from documents import matching
|
||||||
from documents.classifier import DocumentClassifier
|
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 create_source_path_directory
|
||||||
from documents.file_handling import delete_empty_directories
|
from documents.file_handling import delete_empty_directories
|
||||||
from documents.file_handling import generate_unique_filename
|
from documents.file_handling import generate_unique_filename
|
||||||
|
from documents.models import CustomFieldInstance
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
from documents.models import PaperlessTask
|
from documents.models import PaperlessTask
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
|
from documents.models import Workflow
|
||||||
|
from documents.models import WorkflowTrigger
|
||||||
from documents.permissions import get_objects_for_user_owner_aware
|
from documents.permissions import get_objects_for_user_owner_aware
|
||||||
|
from documents.permissions import set_permissions_for_object
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.handlers")
|
logger = logging.getLogger("paperless.handlers")
|
||||||
|
|
||||||
@ -514,6 +519,76 @@ def add_to_index(sender, document, **kwargs):
|
|||||||
index.add_or_update_document(document)
|
index.add_or_update_document(document)
|
||||||
|
|
||||||
|
|
||||||
|
def run_workflows(sender, document: Document, logging_group=None, **kwargs):
|
||||||
|
for workflow in Workflow.objects.filter(
|
||||||
|
triggers__type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
).order_by("order"):
|
||||||
|
if matching.document_matches_workflow(
|
||||||
|
document,
|
||||||
|
workflow,
|
||||||
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
):
|
||||||
|
for action in workflow.actions.all():
|
||||||
|
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,
|
||||||
|
document.document_type.name,
|
||||||
|
document.owner.username,
|
||||||
|
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
|
@before_task_publish.connect
|
||||||
def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
|
def before_task_publish_handler(sender=None, headers=None, body=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from datetime import timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
@ -5,18 +6,21 @@ from unittest import mock
|
|||||||
import pytest
|
import pytest
|
||||||
from django.contrib.auth.models import Group
|
from django.contrib.auth.models import Group
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from documents import tasks
|
from documents import tasks
|
||||||
from documents.data_models import ConsumableDocument
|
from documents.data_models import ConsumableDocument
|
||||||
from documents.data_models import DocumentSource
|
from documents.data_models import DocumentSource
|
||||||
from documents.models import Correspondent
|
from documents.models import Correspondent
|
||||||
from documents.models import CustomField
|
from documents.models import CustomField
|
||||||
|
from documents.models import Document
|
||||||
from documents.models import DocumentType
|
from documents.models import DocumentType
|
||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.models import Workflow
|
from documents.models import Workflow
|
||||||
from documents.models import WorkflowAction
|
from documents.models import WorkflowAction
|
||||||
from documents.models import WorkflowTrigger
|
from documents.models import WorkflowTrigger
|
||||||
|
from documents.signals import document_consumption_finished
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from documents.tests.utils import FileSystemAssertsMixin
|
||||||
from paperless_mail.models import MailAccount
|
from paperless_mail.models import MailAccount
|
||||||
@ -567,32 +571,35 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
self.assertIn(expected_str, cm.output[1])
|
self.assertIn(expected_str, cm.output[1])
|
||||||
|
|
||||||
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
@mock.patch("documents.consumer.Consumer.try_consume_file")
|
||||||
def test_consumption_template_repeat_custom_fields(self, m):
|
def test_workflow_repeat_custom_fields(self, m):
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
- Existing consumption templates which assign the same custom field
|
- Existing workflows which assign the same custom field
|
||||||
WHEN:
|
WHEN:
|
||||||
- File that matches is consumed
|
- File that matches is consumed
|
||||||
THEN:
|
THEN:
|
||||||
- Custom field is added the first time successfully
|
- Custom field is added the first time successfully
|
||||||
"""
|
"""
|
||||||
ct = ConsumptionTemplate.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
name="Template 1",
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
order=0,
|
|
||||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||||
filter_filename="*simple*",
|
filter_filename="*simple*",
|
||||||
)
|
)
|
||||||
ct.assign_custom_fields.add(self.cf1.pk)
|
action1 = WorkflowAction.objects.create()
|
||||||
ct.save()
|
action1.assign_custom_fields.add(self.cf1.pk)
|
||||||
|
action1.save()
|
||||||
|
|
||||||
ct2 = ConsumptionTemplate.objects.create(
|
action2 = WorkflowAction.objects.create()
|
||||||
name="Template 2",
|
action2.assign_custom_fields.add(self.cf1.pk)
|
||||||
order=1,
|
action2.save()
|
||||||
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
||||||
filter_filename="*simple*",
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow 1",
|
||||||
|
order=0,
|
||||||
)
|
)
|
||||||
ct2.assign_custom_fields.add(self.cf1.pk)
|
w.triggers.add(trigger)
|
||||||
ct2.save()
|
w.actions.add(action1, action2)
|
||||||
|
w.save()
|
||||||
|
|
||||||
test_file = self.SAMPLE_DIR / "simple.pdf"
|
test_file = self.SAMPLE_DIR / "simple.pdf"
|
||||||
|
|
||||||
@ -612,7 +619,55 @@ class TestWorkflows(DirectoriesMixin, FileSystemAssertsMixin, TestCase):
|
|||||||
[self.cf1.pk],
|
[self.cf1.pk],
|
||||||
)
|
)
|
||||||
|
|
||||||
expected_str = f"Document matched template {ct}"
|
expected_str = f"Document matched {trigger} from {w}"
|
||||||
self.assertIn(expected_str, cm.output[0])
|
self.assertIn(expected_str, cm.output[0])
|
||||||
expected_str = f"Document matched template {ct2}"
|
|
||||||
self.assertIn(expected_str, cm.output[1])
|
|
||||||
|
def test_document_added_workflow(self):
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
||||||
|
filter_filename="*sample*",
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
assign_title="Doc created in {created_year}",
|
||||||
|
assign_correspondent=self.c2,
|
||||||
|
assign_document_type=self.dt,
|
||||||
|
assign_storage_path=self.sp,
|
||||||
|
assign_owner=self.user2,
|
||||||
|
)
|
||||||
|
action.assign_tags.add(self.t1)
|
||||||
|
action.assign_tags.add(self.t2)
|
||||||
|
action.assign_tags.add(self.t3)
|
||||||
|
action.assign_view_users.add(self.user3.pk)
|
||||||
|
action.assign_view_groups.add(self.group1.pk)
|
||||||
|
action.assign_change_users.add(self.user3.pk)
|
||||||
|
action.assign_change_groups.add(self.group1.pk)
|
||||||
|
action.assign_custom_fields.add(self.cf1.pk)
|
||||||
|
action.assign_custom_fields.add(self.cf2.pk)
|
||||||
|
action.save()
|
||||||
|
w = Workflow.objects.create(
|
||||||
|
name="Workflow 1",
|
||||||
|
order=0,
|
||||||
|
)
|
||||||
|
w.triggers.add(trigger)
|
||||||
|
w.actions.add(action)
|
||||||
|
w.save()
|
||||||
|
|
||||||
|
now = timezone.localtime(timezone.now())
|
||||||
|
created = now - timedelta(weeks=520)
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="sample test",
|
||||||
|
correspondent=self.c,
|
||||||
|
original_filename="sample.pdf",
|
||||||
|
added=now,
|
||||||
|
created=created,
|
||||||
|
)
|
||||||
|
|
||||||
|
document_consumption_finished.send(
|
||||||
|
sender=self.__class__,
|
||||||
|
document=doc,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(doc.correspondent, self.c2)
|
||||||
|
self.assertEqual(doc.title, f"Doc created in {created.year}")
|
||||||
|
Loading…
x
Reference in New Issue
Block a user