mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-24 18:30:31 +00:00
Test modules in paperless, paperless_mail and documents imported filesystem assertions, the migration test base, the retry helper and the streaming-response reader out of documents/tests/utils.py, which kept each app's tests coupled to another app's test package. They now live in paperless_testing, and the progress manager fake is renamed FakeProgressManager and now subclasses the real ProgressManager, overriding only the transport, so the payload it records is built by the production code. The twenty places that patched documents.tasks.ProgressManager by hand now use a fake_progress_manager fixture.
6111 lines
208 KiB
Python
6111 lines
208 KiB
Python
import datetime
|
|
import json
|
|
import shutil
|
|
import socket
|
|
import tempfile
|
|
from collections.abc import Callable
|
|
from datetime import timedelta
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
from typing import Any
|
|
from unittest import mock
|
|
|
|
import pytest
|
|
from django.conf import settings
|
|
from django.contrib.auth.models import Group
|
|
from django.contrib.auth.models import User
|
|
from django.core import mail
|
|
from django.test import override_settings
|
|
from django.utils import timezone
|
|
from guardian.shortcuts import get_groups_with_perms
|
|
from guardian.shortcuts import get_users_with_perms
|
|
from httpx import ConnectError
|
|
from httpx import HTTPError
|
|
from httpx import HTTPStatusError
|
|
from pytest_django.fixtures import Settings
|
|
from pytest_httpx import HTTPXMock
|
|
from rest_framework.test import APIClient
|
|
from rest_framework.test import APITestCase
|
|
|
|
from documents.file_handling import create_source_path_directory
|
|
from documents.file_handling import generate_filename
|
|
from documents.file_handling import generate_unique_filename
|
|
from documents.signals.handlers import run_workflows
|
|
from documents.workflows.ai import apply_ai_suggestions_to_document
|
|
from documents.workflows.webhooks import send_webhook
|
|
from paperless_ai.base_model import ClassificationSuggestions
|
|
from paperless_ai.exceptions import LLMTimeoutError
|
|
|
|
if TYPE_CHECKING:
|
|
from django.db.models import QuerySet
|
|
|
|
from documents import tasks
|
|
from documents.data_models import ConsumableDocument
|
|
from documents.data_models import DocumentMetadataOverrides
|
|
from documents.data_models import DocumentSource
|
|
from documents.matching import document_matches_workflow
|
|
from documents.matching import existing_document_matches_workflow
|
|
from documents.matching import prefilter_documents_by_workflowtrigger
|
|
from documents.models import Correspondent
|
|
from documents.models import CustomField
|
|
from documents.models import CustomFieldInstance
|
|
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 WorkflowAction
|
|
from documents.models import WorkflowActionEmail
|
|
from documents.models import WorkflowActionWebhook
|
|
from documents.models import WorkflowRun
|
|
from documents.models import WorkflowTrigger
|
|
from documents.plugins.base import StopConsumeTaskError
|
|
from documents.serialisers import WorkflowTriggerSerializer
|
|
from documents.signals import document_consumption_finished
|
|
from documents.tests.utils import SampleDirMixin
|
|
from documents.workflows.actions import execute_password_removal_action
|
|
from paperless_mail.models import MailAccount
|
|
from paperless_mail.models import MailRule
|
|
from paperless_testing.assertions import FileSystemAssertsMixin
|
|
from paperless_testing.dirs import DirectoriesMixin
|
|
from paperless_testing.factories import UserFactory
|
|
from paperless_testing.permissions import grant_object
|
|
|
|
|
|
class TestWorkflows(
|
|
DirectoriesMixin,
|
|
FileSystemAssertsMixin,
|
|
SampleDirMixin,
|
|
APITestCase,
|
|
):
|
|
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")
|
|
self.group2 = Group.objects.create(name="group2")
|
|
|
|
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()
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_match(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow
|
|
WHEN:
|
|
- File that matches is consumed
|
|
THEN:
|
|
- Template overrides are applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_filename="*simple*",
|
|
filter_path=f"*/{self.dirs.scratch_dir.parts[-1]}/*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc from {{correspondent}}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
assign_storage_path=self.sp,
|
|
assign_owner=self.user2,
|
|
)
|
|
action.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.assign_custom_fields_values = {
|
|
self.cf2.pk: 42,
|
|
}
|
|
action.save()
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
self.assertEqual(w.__str__(), "Workflow: Workflow 1")
|
|
self.assertEqual(trigger.__str__(), "WorkflowTrigger 1")
|
|
self.assertEqual(action.__str__(), "WorkflowAction 1")
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertEqual(document.correspondent, self.c)
|
|
self.assertEqual(document.document_type, self.dt)
|
|
self.assertEqual(list(document.tags.all()), [self.t1, self.t2, self.t3])
|
|
self.assertEqual(document.storage_path, self.sp)
|
|
self.assertEqual(document.owner, self.user2)
|
|
self.assertEqual(
|
|
list(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
),
|
|
),
|
|
[self.user3],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_groups_with_perms(
|
|
document,
|
|
),
|
|
),
|
|
[self.group1],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["change_document"],
|
|
),
|
|
),
|
|
[self.user3],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_groups_with_perms(
|
|
document,
|
|
),
|
|
),
|
|
[self.group1],
|
|
)
|
|
self.assertEqual(
|
|
document.title,
|
|
f"Doc from {self.c.name}",
|
|
)
|
|
self.assertEqual(
|
|
list(document.custom_fields.all().values_list("field", flat=True)),
|
|
[self.cf1.pk, self.cf2.pk],
|
|
)
|
|
self.assertEqual(
|
|
document.custom_fields.get(field=self.cf2.pk).value,
|
|
42,
|
|
)
|
|
|
|
info = cm.output[0]
|
|
expected_str = f"Document matched {trigger} from {w}"
|
|
self.assertIn(expected_str, info)
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_match_mailrule(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow
|
|
WHEN:
|
|
- File that matches is consumed via mail rule
|
|
THEN:
|
|
- Template overrides are applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_mailrule=self.rule1,
|
|
)
|
|
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc from {{correspondent}}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
assign_storage_path=self.sp,
|
|
assign_owner=self.user2,
|
|
)
|
|
action.assign_tags.add(self.t1)
|
|
action.assign_tags.add(self.t2)
|
|
action.assign_tags.add(self.t3)
|
|
action.assign_view_users.add(self.user3.pk)
|
|
action.assign_view_groups.add(self.group1.pk)
|
|
action.assign_change_users.add(self.user3.pk)
|
|
action.assign_change_groups.add(self.group1.pk)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
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,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertEqual(document.correspondent, self.c)
|
|
self.assertEqual(document.document_type, self.dt)
|
|
self.assertEqual(list(document.tags.all()), [self.t1, self.t2, self.t3])
|
|
self.assertEqual(document.storage_path, self.sp)
|
|
self.assertEqual(document.owner, self.user2)
|
|
self.assertEqual(
|
|
list(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
),
|
|
),
|
|
[self.user3],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_groups_with_perms(
|
|
document,
|
|
),
|
|
),
|
|
[self.group1],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["change_document"],
|
|
),
|
|
),
|
|
[self.user3],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_groups_with_perms(
|
|
document,
|
|
),
|
|
),
|
|
[self.group1],
|
|
)
|
|
self.assertEqual(
|
|
document.title,
|
|
f"Doc from {self.c.name}",
|
|
)
|
|
info = cm.output[0]
|
|
expected_str = f"Document matched {trigger} from {w}"
|
|
self.assertIn(expected_str, info)
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_match_multiple(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Multiple existing workflows
|
|
WHEN:
|
|
- File that matches is consumed
|
|
THEN:
|
|
- Workflow overrides are applied with subsequent workflows overwriting previous values
|
|
or merging if multiple
|
|
"""
|
|
trigger1 = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_path=f"*/{self.dirs.scratch_dir.parts[-1]}/*",
|
|
)
|
|
action1 = WorkflowAction.objects.create(
|
|
assign_title="Doc from {correspondent}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
)
|
|
action1.assign_tags.add(self.t1)
|
|
action1.assign_tags.add(self.t2)
|
|
action1.assign_view_users.add(self.user2)
|
|
action1.save()
|
|
|
|
w1 = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w1.triggers.add(trigger1)
|
|
w1.actions.add(action1)
|
|
w1.save()
|
|
|
|
trigger2 = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_filename="*simple*",
|
|
)
|
|
action2 = WorkflowAction.objects.create(
|
|
assign_title="Doc from {correspondent}",
|
|
assign_correspondent=self.c2,
|
|
assign_storage_path=self.sp,
|
|
)
|
|
action2.assign_tags.add(self.t3)
|
|
action2.assign_view_users.add(self.user3)
|
|
action2.save()
|
|
|
|
w2 = Workflow.objects.create(
|
|
name="Workflow 2",
|
|
order=0,
|
|
)
|
|
w2.triggers.add(trigger2)
|
|
w2.actions.add(action2)
|
|
w2.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
# workflow 1
|
|
self.assertEqual(document.document_type, self.dt)
|
|
# workflow 2
|
|
self.assertEqual(document.correspondent, self.c2)
|
|
self.assertEqual(document.storage_path, self.sp)
|
|
# workflow 1 & 2
|
|
self.assertEqual(
|
|
list(document.tags.all()),
|
|
[self.t1, self.t2, self.t3],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
),
|
|
),
|
|
[self.user2, self.user3],
|
|
)
|
|
|
|
expected_str = f"Document matched {trigger1} from {w1}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document matched {trigger2} from {w2}"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_fnmatch_path(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow
|
|
WHEN:
|
|
- File that matches using fnmatch on path is consumed
|
|
THEN:
|
|
- Template overrides are applied
|
|
- Note: Test was added when path matching changed from pathlib.match to fnmatch
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_path=f"*{self.dirs.scratch_dir.parts[-1]}*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc fnmatch title",
|
|
)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertEqual(document.title, "Doc fnmatch title")
|
|
|
|
expected_str = f"Document matched {trigger} from {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_no_match_filename(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow
|
|
WHEN:
|
|
- File that does not match on filename is consumed
|
|
THEN:
|
|
- Template overrides are not applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_filename="*foobar*",
|
|
filter_path=None,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc from {correspondent}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
assign_storage_path=self.sp,
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertIsNone(document.correspondent)
|
|
self.assertIsNone(document.document_type)
|
|
self.assertEqual(document.tags.all().count(), 0)
|
|
self.assertIsNone(document.storage_path)
|
|
self.assertIsNone(document.owner)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["change_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(document.title, "simple")
|
|
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document filename {test_file.name} does not match"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_no_match_path(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow
|
|
WHEN:
|
|
- File that does not match on path is consumed
|
|
THEN:
|
|
- Template overrides are not applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_path="*foo/bar*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc from {correspondent}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
assign_storage_path=self.sp,
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertIsNone(document.correspondent)
|
|
self.assertIsNone(document.document_type)
|
|
self.assertEqual(document.tags.all().count(), 0)
|
|
self.assertIsNone(document.storage_path)
|
|
self.assertIsNone(document.owner)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["change_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(document.title, "simple")
|
|
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = (
|
|
f"Document path {Path(test_file).resolve(strict=False)} does not match"
|
|
)
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_no_match_mail_rule(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow
|
|
WHEN:
|
|
- File that does not match on source is consumed
|
|
THEN:
|
|
- Template overrides are not applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_mailrule=self.rule1,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc from {correspondent}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
assign_storage_path=self.sp,
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
mailrule_id=99,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertIsNone(document.correspondent)
|
|
self.assertIsNone(document.document_type)
|
|
self.assertEqual(document.tags.all().count(), 0)
|
|
self.assertIsNone(document.storage_path)
|
|
self.assertIsNone(document.owner)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["change_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(document.title, "simple")
|
|
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = "Document mail rule 99 !="
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_no_match_source(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow
|
|
WHEN:
|
|
- File that does not match on source is consumed
|
|
THEN:
|
|
- Template overrides are not applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_path="*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc from {correspondent}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
assign_storage_path=self.sp,
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ApiUpload,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertIsNone(document.correspondent)
|
|
self.assertIsNone(document.document_type)
|
|
self.assertEqual(document.tags.all().count(), 0)
|
|
self.assertIsNone(document.storage_path)
|
|
self.assertIsNone(document.owner)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["change_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(document.title, "simple")
|
|
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document source {DocumentSource.ApiUpload.name} not in ['{DocumentSource.ConsumeFolder.name}', '{DocumentSource.MailFetch.name}']"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_no_match_trigger_type(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
doc.save()
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_matches_workflow(
|
|
doc,
|
|
w,
|
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"No matching triggers with type {WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED} found"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_repeat_custom_fields(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflows which assign the same custom field
|
|
WHEN:
|
|
- File that matches is consumed
|
|
THEN:
|
|
- Custom field is added the first time successfully
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload},{DocumentSource.ConsumeFolder},{DocumentSource.MailFetch}",
|
|
filter_filename="*simple*",
|
|
)
|
|
action1 = WorkflowAction.objects.create()
|
|
action1.assign_custom_fields.add(self.cf1.pk)
|
|
action1.save()
|
|
|
|
action2 = WorkflowAction.objects.create()
|
|
action2.assign_custom_fields.add(self.cf1.pk)
|
|
action2.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action1, action2)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertEqual(
|
|
list(document.custom_fields.all().values_list("field", flat=True)),
|
|
[self.cf1.pk],
|
|
)
|
|
|
|
expected_str = f"Document matched {trigger} from {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
|
|
def test_workflow_assign_custom_field_keeps_storage_filename_in_sync(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing document with a storage path template that depends on a custom field
|
|
- Existing workflow triggered on document update assigning that custom field
|
|
WHEN:
|
|
- Workflow runs for the document
|
|
THEN:
|
|
- The database filename remains aligned with the moved file on disk
|
|
"""
|
|
storage_path = StoragePath.objects.create(
|
|
name="workflow-custom-field-path",
|
|
path="{{ custom_fields|get_cf_value('Custom Field 1', 'none') }}/{{ title }}",
|
|
)
|
|
doc = Document.objects.create(
|
|
title="workflow custom field sync",
|
|
mime_type="application/pdf",
|
|
checksum="workflow-custom-field-sync",
|
|
storage_path=storage_path,
|
|
original_filename="workflow-custom-field-sync.pdf",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=self.cf1,
|
|
value_text="initial",
|
|
)
|
|
|
|
generated = generate_unique_filename(doc)
|
|
destination = (settings.ORIGINALS_DIR / generated).resolve()
|
|
create_source_path_directory(destination)
|
|
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
|
|
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
|
|
doc.refresh_from_db()
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
assign_custom_fields_values={self.cf1.pk: "cars"},
|
|
)
|
|
action.assign_custom_fields.add(self.cf1.pk)
|
|
workflow = Workflow.objects.create(
|
|
name="Workflow custom field filename sync",
|
|
order=0,
|
|
)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
workflow.save()
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
doc.refresh_from_db()
|
|
expected_filename = generate_filename(doc)
|
|
self.assertEqual(Path(doc.filename), expected_filename)
|
|
self.assertTrue(doc.source_path.is_file())
|
|
|
|
def test_workflow_document_updated_does_not_overwrite_filename(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document whose filename has been updated in the DB by a concurrent
|
|
bulk_update_documents task (simulating update_filename_and_move_files
|
|
completing and writing the new filename to the DB)
|
|
- A stale in-memory document instance still holding the old filename
|
|
- An active DOCUMENT_UPDATED workflow
|
|
WHEN:
|
|
- run_workflows is called with the stale in-memory instance
|
|
(as would happen in the second concurrent bulk_update_documents task)
|
|
THEN:
|
|
- The DB filename is NOT overwritten with the stale in-memory value
|
|
(regression test for GH #12386 — the race window between
|
|
refresh_from_db and document.save in run_workflows)
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
assign_title="Updated by workflow",
|
|
)
|
|
workflow = Workflow.objects.create(name="Race condition test workflow", order=0)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
workflow.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="race condition test",
|
|
mime_type="application/pdf",
|
|
checksum="racecondition123",
|
|
original_filename="old.pdf",
|
|
filename="old/path/old.pdf",
|
|
)
|
|
|
|
# Simulate BUD-1 completing update_filename_and_move_files:
|
|
# the DB now holds the new filename while BUD-2's in-memory instance is stale.
|
|
new_filename = "new/path/new.pdf"
|
|
Document.global_objects.filter(pk=doc.pk).update(filename=new_filename)
|
|
|
|
# The stale instance still has filename="old/path/old.pdf" in memory.
|
|
# Mock refresh_from_db so the stale value persists through run_workflows,
|
|
# replicating the race window between refresh and save.
|
|
# Mock update_filename_and_move_files to prevent file-not-found errors
|
|
# since we are only testing DB state here.
|
|
with (
|
|
mock.patch(
|
|
"documents.signals.handlers.update_filename_and_move_files",
|
|
),
|
|
mock.patch.object(Document, "refresh_from_db"),
|
|
):
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
# The DB filename must not have been reverted to the stale old value.
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.filename, new_filename)
|
|
|
|
def test_document_added_workflow(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
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}")
|
|
|
|
def test_document_added_no_match_filename(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_filename="*foobar*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
doc.tags.set([self.t3])
|
|
doc.save()
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document filename {doc.original_filename} does not match"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_match_content_matching(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
matching_algorithm=MatchingModel.MATCH_LITERAL,
|
|
match="foo",
|
|
is_insensitive=True,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc content matching worked",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
content="Hello world foo bar",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"WorkflowTrigger {trigger} matched on document"
|
|
expected_str2 = 'because it contains this string: "foo"'
|
|
self.assertIn(expected_str, cm.output[0])
|
|
self.assertIn(expected_str2, cm.output[0])
|
|
expected_str = f"Document matched {trigger} from {w}"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_no_match_content_matching(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
matching_algorithm=MatchingModel.MATCH_LITERAL,
|
|
match="foo",
|
|
is_insensitive=True,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc content matching worked",
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
content="Hello world bar",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document content matching settings for algorithm '{trigger.matching_algorithm}' did not match"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_no_match_tags(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
trigger.filter_has_tags.set([self.t1, self.t2])
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
doc.tags.set([self.t3])
|
|
doc.save()
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document tags {list(doc.tags.all())} do not include {list(trigger.filter_has_tags.all())}"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_no_match_all_tags(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
trigger.filter_has_all_tags.set([self.t1, self.t2])
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
doc.tags.set([self.t1])
|
|
doc.save()
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = (
|
|
f"Document tags {list(doc.tags.all())} do not contain all of"
|
|
f" {list(trigger.filter_has_all_tags.all())}"
|
|
)
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_excluded_tags(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
trigger.filter_has_not_tags.set([self.t3])
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
doc.tags.set([self.t3])
|
|
doc.save()
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = (
|
|
f"Document tags {list(doc.tags.all())} include excluded tags"
|
|
f" {list(trigger.filter_has_not_tags.all())}"
|
|
)
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_excluded_correspondent(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
trigger.filter_has_not_correspondents.set([self.c])
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = (
|
|
f"Document correspondent {doc.correspondent} is excluded by"
|
|
f" {list(trigger.filter_has_not_correspondents.all())}"
|
|
)
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_excluded_document_types(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
trigger.filter_has_not_document_types.set([self.dt])
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
document_type=self.dt,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = (
|
|
f"Document doc type {doc.document_type} is excluded by"
|
|
f" {list(trigger.filter_has_not_document_types.all())}"
|
|
)
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_excluded_storage_paths(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
trigger.filter_has_not_storage_paths.set([self.sp])
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
storage_path=self.sp,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = (
|
|
f"Document storage path {doc.storage_path} is excluded by"
|
|
f" {list(trigger.filter_has_not_storage_paths.all())}"
|
|
)
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_any_filters(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
trigger.filter_has_any_correspondents.set([self.c])
|
|
trigger.filter_has_any_document_types.set([self.dt])
|
|
trigger.filter_has_any_storage_paths.set([self.sp])
|
|
|
|
matching_doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
document_type=self.dt,
|
|
storage_path=self.sp,
|
|
original_filename="sample.pdf",
|
|
checksum="checksum-any-match",
|
|
)
|
|
|
|
matched, reason = existing_document_matches_workflow(matching_doc, trigger)
|
|
self.assertTrue(matched)
|
|
self.assertIsNone(reason)
|
|
|
|
wrong_correspondent = Document.objects.create(
|
|
title="wrong correspondent",
|
|
correspondent=self.c2,
|
|
document_type=self.dt,
|
|
storage_path=self.sp,
|
|
original_filename="sample2.pdf",
|
|
)
|
|
matched, reason = existing_document_matches_workflow(
|
|
wrong_correspondent,
|
|
trigger,
|
|
)
|
|
self.assertFalse(matched)
|
|
self.assertIn("correspondent", reason)
|
|
|
|
other_document_type = DocumentType.objects.create(name="Other")
|
|
wrong_document_type = Document.objects.create(
|
|
title="wrong doc type",
|
|
correspondent=self.c,
|
|
document_type=other_document_type,
|
|
storage_path=self.sp,
|
|
original_filename="sample3.pdf",
|
|
checksum="checksum-wrong-doc-type",
|
|
)
|
|
matched, reason = existing_document_matches_workflow(
|
|
wrong_document_type,
|
|
trigger,
|
|
)
|
|
self.assertFalse(matched)
|
|
self.assertIn("doc type", reason)
|
|
|
|
other_storage_path = StoragePath.objects.create(
|
|
name="Other path",
|
|
path="/other/",
|
|
)
|
|
wrong_storage_path = Document.objects.create(
|
|
title="wrong storage",
|
|
correspondent=self.c,
|
|
document_type=self.dt,
|
|
storage_path=other_storage_path,
|
|
original_filename="sample4.pdf",
|
|
checksum="checksum-wrong-storage-path",
|
|
)
|
|
matched, reason = existing_document_matches_workflow(
|
|
wrong_storage_path,
|
|
trigger,
|
|
)
|
|
self.assertFalse(matched)
|
|
self.assertIn("storage path", reason)
|
|
|
|
def test_document_added_custom_field_query_no_match(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_custom_field_query=json.dumps(
|
|
[
|
|
"AND",
|
|
[[self.cf1.id, "exact", "expected"]],
|
|
],
|
|
),
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
workflow = Workflow.objects.create(name="Workflow 1", order=0)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
workflow.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=self.cf1,
|
|
value_text="other",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {workflow}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
self.assertIn(
|
|
"Document custom fields do not match the configured custom field query",
|
|
cm.output[1],
|
|
)
|
|
|
|
def test_document_added_custom_field_query_match(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_custom_field_query=json.dumps(
|
|
[
|
|
"AND",
|
|
[[self.cf1.id, "exact", "expected"]],
|
|
],
|
|
),
|
|
)
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=self.cf1,
|
|
value_text="expected",
|
|
)
|
|
|
|
matched, reason = existing_document_matches_workflow(doc, trigger)
|
|
self.assertTrue(matched)
|
|
self.assertIsNone(reason)
|
|
|
|
def test_prefilter_documents_custom_field_query(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_custom_field_query=json.dumps(
|
|
[
|
|
"AND",
|
|
[[self.cf1.id, "exact", "match"]],
|
|
],
|
|
),
|
|
)
|
|
doc1 = Document.objects.create(
|
|
title="doc 1",
|
|
correspondent=self.c,
|
|
original_filename="doc1.pdf",
|
|
checksum="checksum1",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc1,
|
|
field=self.cf1,
|
|
value_text="match",
|
|
)
|
|
|
|
doc2 = Document.objects.create(
|
|
title="doc 2",
|
|
correspondent=self.c,
|
|
original_filename="doc2.pdf",
|
|
checksum="checksum2",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc2,
|
|
field=self.cf1,
|
|
value_text="different",
|
|
)
|
|
|
|
filtered = prefilter_documents_by_workflowtrigger(
|
|
Document.objects.all(),
|
|
trigger,
|
|
)
|
|
self.assertIn(doc1, filtered)
|
|
self.assertNotIn(doc2, filtered)
|
|
|
|
def test_prefilter_documents_any_filters(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
trigger.filter_has_any_correspondents.set([self.c])
|
|
trigger.filter_has_any_document_types.set([self.dt])
|
|
trigger.filter_has_any_storage_paths.set([self.sp])
|
|
|
|
allowed_document = Document.objects.create(
|
|
title="allowed",
|
|
correspondent=self.c,
|
|
document_type=self.dt,
|
|
storage_path=self.sp,
|
|
original_filename="doc-allowed.pdf",
|
|
checksum="checksum-any-allowed",
|
|
)
|
|
blocked_document = Document.objects.create(
|
|
title="blocked",
|
|
correspondent=self.c2,
|
|
document_type=self.dt,
|
|
storage_path=self.sp,
|
|
original_filename="doc-blocked.pdf",
|
|
checksum="checksum-any-blocked",
|
|
)
|
|
|
|
filtered = prefilter_documents_by_workflowtrigger(
|
|
Document.objects.all(),
|
|
trigger,
|
|
)
|
|
|
|
self.assertIn(allowed_document, filtered)
|
|
self.assertNotIn(blocked_document, filtered)
|
|
|
|
def test_consumption_trigger_requires_filter_configuration(self) -> None:
|
|
serializer = WorkflowTriggerSerializer(
|
|
data={
|
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
},
|
|
)
|
|
|
|
self.assertFalse(serializer.is_valid())
|
|
errors = serializer.errors.get("non_field_errors", [])
|
|
self.assertIn(
|
|
"File name, path or mail rule filter are required",
|
|
[str(error) for error in errors],
|
|
)
|
|
|
|
def test_workflow_trigger_serializer_clears_empty_custom_field_query(self) -> None:
|
|
serializer = WorkflowTriggerSerializer(
|
|
data={
|
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
"filter_custom_field_query": "",
|
|
},
|
|
)
|
|
|
|
self.assertTrue(serializer.is_valid(), serializer.errors)
|
|
self.assertIsNone(serializer.validated_data.get("filter_custom_field_query"))
|
|
|
|
def test_existing_document_invalid_custom_field_query_configuration(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_custom_field_query="{ not json",
|
|
)
|
|
|
|
document = Document.objects.create(
|
|
title="doc invalid query",
|
|
original_filename="invalid.pdf",
|
|
checksum="checksum-invalid-query",
|
|
)
|
|
|
|
matched, reason = existing_document_matches_workflow(document, trigger)
|
|
self.assertFalse(matched)
|
|
self.assertEqual(reason, "Invalid custom field query configuration")
|
|
|
|
def test_prefilter_documents_returns_none_for_invalid_custom_field_query(
|
|
self,
|
|
) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_custom_field_query="{ not json",
|
|
)
|
|
|
|
Document.objects.create(
|
|
title="doc",
|
|
original_filename="doc.pdf",
|
|
checksum="checksum-prefilter-invalid",
|
|
)
|
|
|
|
filtered = prefilter_documents_by_workflowtrigger(
|
|
Document.objects.all(),
|
|
trigger,
|
|
)
|
|
|
|
self.assertEqual(list(filtered), [])
|
|
|
|
def test_prefilter_documents_applies_all_filters(self) -> None:
|
|
other_document_type = DocumentType.objects.create(name="Other Type")
|
|
other_storage_path = StoragePath.objects.create(
|
|
name="Blocked path",
|
|
path="/blocked/",
|
|
)
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_has_correspondent=self.c,
|
|
filter_has_document_type=self.dt,
|
|
filter_has_storage_path=self.sp,
|
|
)
|
|
trigger.filter_has_tags.set([self.t1])
|
|
trigger.filter_has_all_tags.set([self.t1, self.t2])
|
|
trigger.filter_has_not_tags.set([self.t3])
|
|
trigger.filter_has_not_correspondents.set([self.c2])
|
|
trigger.filter_has_not_document_types.set([other_document_type])
|
|
trigger.filter_has_not_storage_paths.set([other_storage_path])
|
|
|
|
allowed_document = Document.objects.create(
|
|
title="allowed",
|
|
correspondent=self.c,
|
|
document_type=self.dt,
|
|
storage_path=self.sp,
|
|
original_filename="allow.pdf",
|
|
checksum="checksum-prefilter-allowed",
|
|
)
|
|
allowed_document.tags.set([self.t1, self.t2])
|
|
|
|
blocked_document = Document.objects.create(
|
|
title="blocked",
|
|
correspondent=self.c2,
|
|
document_type=other_document_type,
|
|
storage_path=other_storage_path,
|
|
original_filename="block.pdf",
|
|
checksum="checksum-prefilter-blocked",
|
|
)
|
|
blocked_document.tags.set([self.t1, self.t3])
|
|
|
|
filtered = prefilter_documents_by_workflowtrigger(
|
|
Document.objects.all(),
|
|
trigger,
|
|
)
|
|
|
|
self.assertIn(allowed_document, filtered)
|
|
self.assertNotIn(blocked_document, filtered)
|
|
|
|
def test_document_added_no_match_doctype(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_has_document_type=self.dt,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document doc type {doc.document_type} does not match {trigger.filter_has_document_type}"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_no_match_correspondent(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_has_correspondent=self.c,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
action.save()
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c2,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document correspondent {doc.correspondent} does not match {trigger.filter_has_correspondent}"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_no_match_storage_path(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_has_storage_path=self.sp,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="DEBUG") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
expected_str = f"Document did not match {w}"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = f"Document storage path {doc.storage_path} does not match {trigger.filter_has_storage_path}"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
def test_document_added_invalid_title_placeholders(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with added trigger type
|
|
- Assign title field has an error
|
|
WHEN:
|
|
- File that matches is added
|
|
THEN:
|
|
- Title is updated but the placeholder isn't replaced
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_filename="*sample*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc {created_year]",
|
|
)
|
|
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(
|
|
original_filename="sample.pdf",
|
|
title="sample test",
|
|
content="Hello world bar",
|
|
created=created,
|
|
)
|
|
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
|
|
self.assertEqual(doc.title, "Doc {created_year]")
|
|
|
|
def test_document_added_malformed_title_template_falls_back(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with added trigger type
|
|
- Assign title field is malformed Jinja2 syntax
|
|
WHEN:
|
|
- File that matches is added
|
|
THEN:
|
|
- Title assignment is skipped and the original title is kept
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_filename="*sample*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc {{ unclosed",
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
original_filename="sample.pdf",
|
|
title="sample test",
|
|
content="Hello world bar",
|
|
)
|
|
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.title, "sample test")
|
|
|
|
def test_document_updated_workflow_ignores_version_documents(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
workflow = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
|
|
root_doc = Document.objects.create(
|
|
title="root",
|
|
correspondent=self.c,
|
|
original_filename="root.pdf",
|
|
)
|
|
version_doc = Document.objects.create(
|
|
title="version",
|
|
correspondent=self.c,
|
|
original_filename="version.pdf",
|
|
root_document=root_doc,
|
|
)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, version_doc)
|
|
|
|
root_doc.refresh_from_db()
|
|
version_doc.refresh_from_db()
|
|
|
|
self.assertIsNone(root_doc.owner)
|
|
self.assertIsNone(version_doc.owner)
|
|
self.assertFalse(
|
|
WorkflowRun.objects.filter(
|
|
workflow=workflow,
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
document=version_doc,
|
|
).exists(),
|
|
)
|
|
|
|
def test_document_updated_workflow(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
filter_has_document_type=self.dt,
|
|
)
|
|
action = WorkflowAction.objects.create()
|
|
action.assign_custom_fields.add(self.cf1)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"document_type": self.dt.id},
|
|
format="json",
|
|
)
|
|
|
|
self.assertEqual(doc.custom_fields.all().count(), 1)
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_document_consumption_workflow_month_placeholder_addded(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload}",
|
|
filter_filename="simple*",
|
|
)
|
|
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc added in {{added_month_name_short}}",
|
|
)
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ApiUpload,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertRegex(
|
|
document.title,
|
|
r"Doc added in \w{3,}",
|
|
) # Match any 3-letter month name
|
|
|
|
def test_document_updated_workflow_existing_custom_field_empty_value(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with UPDATED trigger and action that assigns a custom field
|
|
with an empty value
|
|
WHEN:
|
|
- Document is updated that already contains the field with a value
|
|
THEN:
|
|
- The existing value is left untouched, see GH #13627
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
filter_has_document_type=self.dt,
|
|
)
|
|
action = WorkflowAction.objects.create()
|
|
action.assign_custom_fields.add(self.cf1)
|
|
action.assign_custom_fields_values = {self.cf1.pk: ""}
|
|
action.save()
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=self.cf1,
|
|
value_text="existing value",
|
|
)
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"document_type": self.dt.id},
|
|
format="json",
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.custom_fields.get(field=self.cf1).value, "existing value")
|
|
|
|
def test_document_updated_workflow_existing_custom_field(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with UPDATED trigger and action that assigns a custom field with a value
|
|
WHEN:
|
|
- Document is updated that already contains the field
|
|
THEN:
|
|
- Document update succeeds and updates the field
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
filter_has_document_type=self.dt,
|
|
)
|
|
action = WorkflowAction.objects.create()
|
|
action.assign_custom_fields.add(self.cf1)
|
|
action.assign_custom_fields_values = {self.cf1.pk: "new value"}
|
|
action.save()
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
CustomFieldInstance.objects.create(document=doc, field=self.cf1)
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"document_type": self.dt.id},
|
|
format="json",
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.custom_fields.get(field=self.cf1).value, "new value")
|
|
|
|
def test_document_updated_workflow_merge_permissions(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with UPDATED trigger and action that sets permissions
|
|
WHEN:
|
|
- Document is updated that already has permissions
|
|
THEN:
|
|
- Permissions are merged
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
filter_has_document_type=self.dt,
|
|
)
|
|
action = WorkflowAction.objects.create()
|
|
action.assign_view_users.add(self.user3)
|
|
action.assign_change_users.add(self.user3)
|
|
action.assign_view_groups.add(self.group2)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
grant_object(self.user2, doc, "documents.view_document")
|
|
grant_object(self.user2, doc, "documents.change_document")
|
|
grant_object(self.group1, doc, "documents.view_document")
|
|
grant_object(self.group1, doc, "documents.change_document")
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"document_type": self.dt.id},
|
|
format="json",
|
|
)
|
|
|
|
view_users_perms: QuerySet[Any] = get_users_with_perms(
|
|
doc,
|
|
only_with_perms_in=["view_document"],
|
|
)
|
|
change_users_perms: QuerySet[Any] = get_users_with_perms(
|
|
doc,
|
|
only_with_perms_in=["change_document"],
|
|
)
|
|
# user2 should still have permissions
|
|
self.assertIn(self.user2, view_users_perms)
|
|
self.assertIn(self.user2, change_users_perms)
|
|
# user3 should have been added
|
|
self.assertIn(self.user3, view_users_perms)
|
|
self.assertIn(self.user3, change_users_perms)
|
|
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(doc)
|
|
# group1 should still have permissions
|
|
self.assertIn(self.group1, group_perms)
|
|
# group2 should have been added
|
|
self.assertIn(self.group2, group_perms)
|
|
|
|
def test_workflow_scheduled_trigger_created(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with SCHEDULED trigger against the created field and action that assigns owner
|
|
- Existing doc that matches the trigger
|
|
- Workflow set to trigger at (now - offset) = now - 1 day
|
|
- Document created date is 2 days ago → trigger condition met
|
|
WHEN:
|
|
- Scheduled workflows are checked
|
|
THEN:
|
|
- Workflow runs, document owner is updated
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=1,
|
|
schedule_date_field="created",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
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(days=2)
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
created=created,
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.owner, self.user2)
|
|
|
|
@mock.patch("documents.tasks.send_websocket_document_updated")
|
|
def test_workflow_scheduled_trigger_sends_websocket_update(
|
|
self,
|
|
mock_send_websocket_document_updated,
|
|
) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=1,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
|
)
|
|
action = WorkflowAction.objects.create(assign_owner=self.user2)
|
|
workflow = Workflow.objects.create(name="Workflow 1", order=0)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
created=timezone.now() - timedelta(days=2),
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
self.assertEqual(mock_send_websocket_document_updated.call_count, 1)
|
|
self.assertEqual(
|
|
mock_send_websocket_document_updated.call_args.kwargs["document"].pk,
|
|
doc.pk,
|
|
)
|
|
|
|
def test_workflow_scheduled_trigger_added(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with SCHEDULED trigger against the added field and action that assigns owner
|
|
- Existing doc that matches the trigger
|
|
- Workflow set to trigger at (now - offset) = now - 1 day
|
|
- Document added date is 365 days ago
|
|
WHEN:
|
|
- Scheduled workflows are checked
|
|
THEN:
|
|
- Workflow runs, document owner is updated
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=1,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.ADDED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
added = timezone.now() - timedelta(days=365)
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
added=added,
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.owner, self.user2)
|
|
|
|
def test_workflow_scheduled_trigger_ignores_version_documents(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=1,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.ADDED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
workflow = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
|
|
root_doc = Document.objects.create(
|
|
title="root",
|
|
correspondent=self.c,
|
|
original_filename="root.pdf",
|
|
added=timezone.now() - timedelta(days=10),
|
|
)
|
|
version_doc = Document.objects.create(
|
|
title="version",
|
|
correspondent=self.c,
|
|
original_filename="version.pdf",
|
|
root_document=root_doc,
|
|
added=timezone.now() - timedelta(days=10),
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
root_doc.refresh_from_db()
|
|
version_doc.refresh_from_db()
|
|
|
|
self.assertEqual(root_doc.owner, self.user2)
|
|
self.assertIsNone(version_doc.owner)
|
|
self.assertEqual(
|
|
WorkflowRun.objects.filter(
|
|
workflow=workflow,
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
document=root_doc,
|
|
).count(),
|
|
1,
|
|
)
|
|
self.assertFalse(
|
|
WorkflowRun.objects.filter(
|
|
workflow=workflow,
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
document=version_doc,
|
|
).exists(),
|
|
)
|
|
|
|
@mock.patch("documents.models.Document.objects.filter", autospec=True)
|
|
def test_workflow_scheduled_trigger_modified(self, mock_filter) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with SCHEDULED trigger against the modified field and action that assigns owner
|
|
- Existing doc that matches the trigger
|
|
- Workflow set to trigger at (now - offset) = now - 1 day
|
|
- Document modified date is mocked as sufficiently in the past
|
|
WHEN:
|
|
- Scheduled workflows are checked
|
|
THEN:
|
|
- Workflow runs, document owner is updated
|
|
"""
|
|
# we have to mock because modified field is auto_now
|
|
mock_filter.return_value = Document.objects.all()
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=1,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.MODIFIED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.owner, self.user2)
|
|
|
|
def test_workflow_scheduled_trigger_custom_field(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with SCHEDULED trigger against a custom field and action that assigns owner
|
|
- Existing doc that matches the trigger
|
|
- Workflow set to trigger at (now - offset) = now - 1 day
|
|
- Custom field date is 2 days ago
|
|
WHEN:
|
|
- Scheduled workflows are checked
|
|
THEN:
|
|
- Workflow runs, document owner is updated
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=1,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CUSTOM_FIELD,
|
|
schedule_date_custom_field=self.cf1,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc,
|
|
field=self.cf1,
|
|
value_date=timezone.now() - timedelta(days=2),
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.owner, self.user2)
|
|
|
|
def test_workflow_scheduled_already_run(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with SCHEDULED trigger
|
|
- Existing doc that has already had the workflow run
|
|
- Document created 2 days ago, workflow offset = 1 day → trigger time = yesterday
|
|
WHEN:
|
|
- Scheduled workflows are checked
|
|
THEN:
|
|
- Workflow does not run again
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=1,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
created=timezone.now() - timedelta(days=2),
|
|
)
|
|
|
|
wr = WorkflowRun.objects.create(
|
|
workflow=w,
|
|
document=doc,
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
run_at=timezone.now(),
|
|
)
|
|
self.assertEqual(
|
|
str(wr),
|
|
f"WorkflowRun of {w} at {wr.run_at} on {doc}",
|
|
) # coverage
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
doc.refresh_from_db()
|
|
self.assertIsNone(doc.owner)
|
|
|
|
def test_workflow_scheduled_trigger_too_early(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with SCHEDULED trigger and recurring interval of 7 days
|
|
- Workflow run date is 6 days ago
|
|
- Document created 40 days ago, offset = 30 → trigger time = 10 days ago
|
|
WHEN:
|
|
- Scheduled workflows are checked
|
|
THEN:
|
|
- Workflow does not run as the offset is not met
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=30,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
|
schedule_is_recurring=True,
|
|
schedule_recurring_interval_days=7,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
created=timezone.now() - timedelta(days=40),
|
|
)
|
|
|
|
WorkflowRun.objects.create(
|
|
workflow=w,
|
|
document=doc,
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
run_at=timezone.now() - timedelta(days=6),
|
|
)
|
|
|
|
with self.assertLogs(level="DEBUG") as cm:
|
|
tasks.check_scheduled_workflows()
|
|
self.assertIn(
|
|
"last run was within the recurring interval",
|
|
" ".join(cm.output),
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertIsNone(doc.owner)
|
|
|
|
def test_workflow_scheduled_recurring_respects_latest_run(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Scheduled workflow marked as recurring with a 1-day interval
|
|
- Document that matches the trigger
|
|
- Two prior runs exist: one 2 days ago and one 1 hour ago
|
|
WHEN:
|
|
- Scheduled workflows are checked again
|
|
THEN:
|
|
- Workflow does not run because the most recent run is inside the interval
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
|
schedule_is_recurring=True,
|
|
schedule_recurring_interval_days=1,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
created=timezone.now().date() - timedelta(days=3),
|
|
)
|
|
|
|
WorkflowRun.objects.create(
|
|
workflow=w,
|
|
document=doc,
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
run_at=timezone.now() - timedelta(days=2),
|
|
)
|
|
WorkflowRun.objects.create(
|
|
workflow=w,
|
|
document=doc,
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
run_at=timezone.now() - timedelta(hours=1),
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
doc.refresh_from_db()
|
|
self.assertIsNone(doc.owner)
|
|
self.assertEqual(
|
|
WorkflowRun.objects.filter(
|
|
workflow=w,
|
|
document=doc,
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
).count(),
|
|
2,
|
|
)
|
|
|
|
def test_workflow_scheduled_trigger_negative_offset_customfield(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with offset -7 (i.e., 7 days *before* the date)
|
|
- doc1: value_date = 5 days ago → trigger time = 12 days ago → triggers
|
|
- doc2: value_date = 9 days in future → trigger time = 2 days in future → does NOT trigger
|
|
WHEN:
|
|
- Scheduled workflows are checked
|
|
THEN:
|
|
- doc1 has owner assigned
|
|
- doc2 remains untouched
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=-7,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CUSTOM_FIELD,
|
|
schedule_date_custom_field=self.cf1,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc1 = Document.objects.create(
|
|
title="doc1",
|
|
correspondent=self.c,
|
|
original_filename="doc1.pdf",
|
|
checksum="doc1-checksum",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc1,
|
|
field=self.cf1,
|
|
value_date=timezone.now().date() - timedelta(days=5),
|
|
)
|
|
|
|
doc2 = Document.objects.create(
|
|
title="doc2",
|
|
correspondent=self.c,
|
|
original_filename="doc2.pdf",
|
|
checksum="doc2-checksum",
|
|
)
|
|
CustomFieldInstance.objects.create(
|
|
document=doc2,
|
|
field=self.cf1,
|
|
value_date=timezone.now().date() + timedelta(days=9),
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
|
|
doc1.refresh_from_db()
|
|
self.assertEqual(doc1.owner, self.user2)
|
|
|
|
doc2.refresh_from_db()
|
|
self.assertIsNone(doc2.owner)
|
|
|
|
def test_workflow_scheduled_trigger_negative_offset_created(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with SCHEDULED trigger and negative offset of -7 days (so 7 days before date)
|
|
- doc created 8 days ago → trigger time = 15 days ago → triggers
|
|
- doc2 created 8 days *in the future* → trigger time = 1 day in future → does NOT trigger
|
|
WHEN:
|
|
- Scheduled workflows are checked for document
|
|
THEN:
|
|
- doc is matched and owner updated
|
|
- doc2 is untouched
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=-7,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
checksum="1",
|
|
created=timezone.now().date() - timedelta(days=8),
|
|
)
|
|
|
|
doc2 = Document.objects.create(
|
|
title="sample test 2",
|
|
correspondent=self.c,
|
|
original_filename="sample2.pdf",
|
|
checksum="2",
|
|
created=timezone.now().date() + timedelta(days=8),
|
|
)
|
|
|
|
tasks.check_scheduled_workflows()
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.owner, self.user2)
|
|
doc2.refresh_from_db()
|
|
self.assertIsNone(doc2.owner) # has not triggered yet
|
|
|
|
def test_offset_positive_means_after(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document created 30 days ago
|
|
- Workflow with offset +10
|
|
EXPECT:
|
|
- It triggers now, because created + 10 = 20 days ago < now
|
|
"""
|
|
doc = Document.objects.create(
|
|
title="Test doc",
|
|
created=timezone.now() - timedelta(days=30),
|
|
correspondent=self.c,
|
|
original_filename="test.pdf",
|
|
)
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
|
schedule_offset_days=10,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
tasks.check_scheduled_workflows()
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.owner, self.user2)
|
|
|
|
def test_workflow_scheduled_filters_queryset(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Existing workflow with scheduled trigger
|
|
WHEN:
|
|
- Workflows run and matching documents are found
|
|
THEN:
|
|
- prefilter_documents_by_workflowtrigger appropriately filters
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
|
|
schedule_offset_days=-7,
|
|
schedule_date_field=WorkflowTrigger.ScheduleDateField.CREATED,
|
|
filter_filename="*sample*",
|
|
filter_has_document_type=self.dt,
|
|
filter_has_correspondent=self.c,
|
|
filter_has_storage_path=self.sp,
|
|
)
|
|
trigger.filter_has_tags.set([self.t1])
|
|
trigger.save()
|
|
action = WorkflowAction.objects.create(
|
|
assign_owner=self.user2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
# create 10 docs with half having the document type
|
|
for i in range(10):
|
|
doc = Document.objects.create(
|
|
title=f"sample test {i}",
|
|
checksum=f"checksum{i}",
|
|
correspondent=self.c,
|
|
storage_path=self.sp,
|
|
original_filename=f"sample_{i}.pdf",
|
|
document_type=self.dt if i % 2 == 0 else None,
|
|
)
|
|
doc.tags.set([self.t1])
|
|
doc.save()
|
|
|
|
documents = Document.objects.all()
|
|
filtered_docs = prefilter_documents_by_workflowtrigger(
|
|
documents,
|
|
trigger,
|
|
)
|
|
self.assertEqual(filtered_docs.count(), 5)
|
|
|
|
def test_workflow_enabled_disabled(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_filename="*sample*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Title assign correspondent",
|
|
assign_correspondent=self.c2,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
enabled=False,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
action2 = WorkflowAction.objects.create(
|
|
assign_title="Title assign owner",
|
|
assign_owner=self.user2,
|
|
)
|
|
w2 = Workflow.objects.create(
|
|
name="Workflow 2",
|
|
order=0,
|
|
enabled=True,
|
|
)
|
|
w2.triggers.add(trigger)
|
|
w2.actions.add(action2)
|
|
w2.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
|
|
self.assertEqual(doc.correspondent, self.c)
|
|
self.assertEqual(doc.title, "Title assign owner")
|
|
self.assertEqual(doc.owner, self.user2)
|
|
|
|
def test_new_trigger_type_raises_exception(self) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=99,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc assign owner",
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="test",
|
|
)
|
|
self.assertRaises(Exception, document_matches_workflow, doc, w, 99)
|
|
|
|
def test_removal_action_document_updated_workflow(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with removal action
|
|
WHEN:
|
|
- File that matches is updated
|
|
THEN:
|
|
- Action removals are applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
filter_path="*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
|
)
|
|
action.remove_correspondents.add(self.c)
|
|
action.remove_tags.add(self.t1)
|
|
action.remove_document_types.add(self.dt)
|
|
action.remove_storage_paths.add(self.sp)
|
|
action.remove_owners.add(self.user2)
|
|
action.remove_custom_fields.add(self.cf1)
|
|
action.remove_view_users.add(self.user3)
|
|
action.remove_view_groups.add(self.group1)
|
|
action.remove_change_users.add(self.user3)
|
|
action.remove_change_groups.add(self.group1)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
document_type=self.dt,
|
|
storage_path=self.sp,
|
|
owner=self.user2,
|
|
original_filename="sample.pdf",
|
|
)
|
|
doc.tags.set([self.t1, self.t2])
|
|
CustomFieldInstance.objects.create(document=doc, field=self.cf1)
|
|
doc.save()
|
|
grant_object(self.user3, doc, "documents.view_document")
|
|
grant_object(self.user3, doc, "documents.change_document")
|
|
grant_object(self.group1, doc, "documents.view_document")
|
|
grant_object(self.group1, doc, "documents.change_document")
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"title": "new title"},
|
|
format="json",
|
|
)
|
|
doc.refresh_from_db()
|
|
|
|
self.assertIsNone(doc.document_type)
|
|
self.assertIsNone(doc.correspondent)
|
|
self.assertIsNone(doc.storage_path)
|
|
self.assertEqual(doc.tags.all().count(), 1)
|
|
self.assertIn(self.t2, doc.tags.all())
|
|
self.assertIsNone(doc.owner)
|
|
self.assertEqual(doc.custom_fields.all().count(), 0)
|
|
self.assertFalse(self.user3.has_perm("documents.view_document", doc))
|
|
self.assertFalse(self.user3.has_perm("documents.change_document", doc))
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(doc)
|
|
self.assertNotIn(self.group1, group_perms)
|
|
|
|
def test_document_updated_workflow_assignment_persists_when_removing_trigger_tag(
|
|
self,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document updated workflow filtered on a tag
|
|
- The workflow assigns a new title and removes that same tag
|
|
WHEN:
|
|
- The document is updated while carrying the trigger tag
|
|
THEN:
|
|
- The new title persists and the trigger tag is removed
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
trigger.filter_has_tags.add(self.t1)
|
|
assignment = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
assign_title="workflow renamed",
|
|
order=0,
|
|
)
|
|
removal = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
|
order=1,
|
|
)
|
|
removal.remove_tags.add(self.t1)
|
|
removal.save()
|
|
|
|
workflow = Workflow.objects.create(
|
|
name="Workflow rename and remove trigger tag",
|
|
order=0,
|
|
)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(assignment, removal)
|
|
workflow.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
mime_type="application/pdf",
|
|
checksum="rename-remove-trigger-tag",
|
|
original_filename="sample.pdf",
|
|
)
|
|
generated = generate_unique_filename(doc)
|
|
destination = (settings.ORIGINALS_DIR / generated).resolve()
|
|
create_source_path_directory(destination)
|
|
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
|
|
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
|
|
doc.refresh_from_db()
|
|
doc.tags.set([self.t1, self.t2])
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"title": "user update to trigger workflow"},
|
|
format="json",
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.title, "workflow renamed")
|
|
self.assertFalse(doc.tags.filter(pk=self.t1.pk).exists())
|
|
self.assertTrue(doc.tags.filter(pk=self.t2.pk).exists())
|
|
|
|
def test_document_updated_workflow_assignment_storage_path_persists_with_tag_assignment(
|
|
self,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document updated workflow filtered on a tag
|
|
- One assignment action assigns a storage path, a second (later-ordered)
|
|
assignment action adds a tag
|
|
WHEN:
|
|
- The document is updated and the workflow is triggered
|
|
THEN:
|
|
- Both the tag and the storage path are persisted
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
trigger.filter_has_tags.add(self.t1)
|
|
assign_storage_path = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
assign_storage_path=self.sp,
|
|
order=0,
|
|
)
|
|
assign_tag = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
order=1,
|
|
)
|
|
assign_tag.assign_tags.add(self.t2)
|
|
assign_tag.save()
|
|
|
|
workflow = Workflow.objects.create(
|
|
name="Workflow assign storage path then tag",
|
|
order=0,
|
|
)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(assign_storage_path, assign_tag)
|
|
workflow.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
mime_type="application/pdf",
|
|
checksum="assign-tag-and-storage-path",
|
|
original_filename="sample.pdf",
|
|
)
|
|
generated = generate_unique_filename(doc)
|
|
destination = (settings.ORIGINALS_DIR / generated).resolve()
|
|
create_source_path_directory(destination)
|
|
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
|
|
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
|
|
doc.refresh_from_db()
|
|
doc.tags.set([self.t1])
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"title": "user update to trigger workflow"},
|
|
format="json",
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.storage_path, self.sp)
|
|
self.assertTrue(doc.tags.filter(pk=self.t2.pk).exists())
|
|
|
|
def test_removal_action_document_updated_removeall(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with removal action with remove all fields set
|
|
WHEN:
|
|
- File that matches is updated
|
|
THEN:
|
|
- Action removals are applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
filter_path="*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
|
remove_all_correspondents=True,
|
|
remove_all_tags=True,
|
|
remove_all_document_types=True,
|
|
remove_all_storage_paths=True,
|
|
remove_all_custom_fields=True,
|
|
remove_all_owners=True,
|
|
remove_all_permissions=True,
|
|
)
|
|
action.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
document_type=self.dt,
|
|
storage_path=self.sp,
|
|
owner=self.user2,
|
|
original_filename="sample.pdf",
|
|
)
|
|
doc.tags.set([self.t1, self.t2])
|
|
CustomFieldInstance.objects.create(document=doc, field=self.cf1)
|
|
doc.save()
|
|
grant_object(self.user3, doc, "documents.view_document")
|
|
grant_object(self.user3, doc, "documents.change_document")
|
|
grant_object(self.group1, doc, "documents.view_document")
|
|
grant_object(self.group1, doc, "documents.change_document")
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"title": "new title"},
|
|
format="json",
|
|
)
|
|
doc.refresh_from_db()
|
|
|
|
self.assertIsNone(doc.document_type)
|
|
self.assertIsNone(doc.correspondent)
|
|
self.assertIsNone(doc.storage_path)
|
|
self.assertEqual(doc.tags.all().count(), 0)
|
|
self.assertEqual(doc.tags.all().count(), 0)
|
|
self.assertIsNone(doc.owner)
|
|
self.assertEqual(doc.custom_fields.all().count(), 0)
|
|
self.assertFalse(self.user3.has_perm("documents.view_document", doc))
|
|
self.assertFalse(self.user3.has_perm("documents.change_document", doc))
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(doc)
|
|
self.assertNotIn(self.group1, group_perms)
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_removal_action_document_consumed(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with assignment and removal actions
|
|
WHEN:
|
|
- File that matches is consumed
|
|
THEN:
|
|
- Action removals are applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
filter_filename="*simple*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc from {{correspondent}}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
assign_storage_path=self.sp,
|
|
assign_owner=self.user2,
|
|
)
|
|
action.assign_tags.add(self.t1)
|
|
action.assign_tags.add(self.t2)
|
|
action.assign_tags.add(self.t3)
|
|
action.assign_view_users.add(self.user2)
|
|
action.assign_view_users.add(self.user3)
|
|
action.assign_view_groups.add(self.group1)
|
|
action.assign_view_groups.add(self.group2)
|
|
action.assign_change_users.add(self.user2)
|
|
action.assign_change_users.add(self.user3)
|
|
action.assign_change_groups.add(self.group1)
|
|
action.assign_change_groups.add(self.group2)
|
|
action.assign_custom_fields.add(self.cf1)
|
|
action.assign_custom_fields.add(self.cf2)
|
|
action.save()
|
|
|
|
action2 = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
|
)
|
|
action2.remove_correspondents.add(self.c)
|
|
action2.remove_tags.add(self.t1)
|
|
action2.remove_document_types.add(self.dt)
|
|
action2.remove_storage_paths.add(self.sp)
|
|
action2.remove_owners.add(self.user2)
|
|
action2.remove_custom_fields.add(self.cf1)
|
|
action2.remove_view_users.add(self.user3)
|
|
action2.remove_change_users.add(self.user3)
|
|
action2.remove_view_groups.add(self.group1)
|
|
action2.remove_change_groups.add(self.group1)
|
|
action2.save()
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.actions.add(action2)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
|
|
self.assertIsNone(document.correspondent)
|
|
self.assertIsNone(document.document_type)
|
|
self.assertEqual(
|
|
list(document.tags.all()),
|
|
[self.t2, self.t3],
|
|
)
|
|
self.assertIsNone(document.storage_path)
|
|
self.assertIsNone(document.owner)
|
|
self.assertEqual(
|
|
list(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
),
|
|
),
|
|
[self.user2],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_groups_with_perms(
|
|
document,
|
|
),
|
|
),
|
|
[self.group2],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["change_document"],
|
|
),
|
|
),
|
|
[self.user2],
|
|
)
|
|
self.assertEqual(
|
|
list(
|
|
get_groups_with_perms(
|
|
document,
|
|
),
|
|
),
|
|
[self.group2],
|
|
)
|
|
self.assertEqual(
|
|
document.title,
|
|
"Doc from None",
|
|
)
|
|
self.assertEqual(
|
|
list(document.custom_fields.all().values_list("field", flat=True)),
|
|
[self.cf2.pk],
|
|
)
|
|
|
|
info = cm.output[0]
|
|
expected_str = f"Document matched {trigger} from {w}"
|
|
self.assertIn(expected_str, info)
|
|
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_removal_action_document_consumed_remove_all(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with assignment and removal actions with remove all fields set
|
|
WHEN:
|
|
- File that matches is consumed
|
|
THEN:
|
|
- Action removals are applied
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
filter_filename="*simple*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
assign_title="Doc from {correspondent}",
|
|
assign_correspondent=self.c,
|
|
assign_document_type=self.dt,
|
|
assign_storage_path=self.sp,
|
|
assign_owner=self.user2,
|
|
)
|
|
action.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()
|
|
|
|
action2 = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
|
remove_all_correspondents=True,
|
|
remove_all_tags=True,
|
|
remove_all_document_types=True,
|
|
remove_all_storage_paths=True,
|
|
remove_all_custom_fields=True,
|
|
remove_all_owners=True,
|
|
remove_all_permissions=True,
|
|
)
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.actions.add(action2)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="INFO") as cm:
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
self.assertIsNone(document.correspondent)
|
|
self.assertIsNone(document.document_type)
|
|
self.assertEqual(document.tags.all().count(), 0)
|
|
|
|
self.assertIsNone(document.storage_path)
|
|
self.assertIsNone(document.owner)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["view_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(
|
|
get_users_with_perms(
|
|
document,
|
|
only_with_perms_in=["change_document"],
|
|
).count(),
|
|
0,
|
|
)
|
|
group_perms: QuerySet[Any] = get_groups_with_perms(document)
|
|
self.assertEqual(group_perms.count(), 0)
|
|
self.assertEqual(
|
|
document.custom_fields.all()
|
|
.values_list(
|
|
"field",
|
|
)
|
|
.count(),
|
|
0,
|
|
)
|
|
|
|
info = cm.output[0]
|
|
expected_str = f"Document matched {trigger} from {w}"
|
|
self.assertIn(expected_str, info)
|
|
|
|
def test_workflow_with_tag_actions_doesnt_overwrite_other_actions(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow filtered by has tag with two actions, first adds owner, second removes a tag
|
|
WHEN:
|
|
- File that matches is consumed
|
|
THEN:
|
|
- Both actions are applied correctly
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
trigger.filter_has_tags.add(self.t1)
|
|
action1 = WorkflowAction.objects.create(
|
|
assign_owner=self.user2,
|
|
)
|
|
action2 = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
|
)
|
|
action2.remove_tags.add(self.t1)
|
|
w = Workflow.objects.create(
|
|
name="Workflow Add Owner and Remove Tag",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action1)
|
|
w.actions.add(action2)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
self.client.force_authenticate(user=superuser)
|
|
|
|
self.client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"tags": [self.t1.id, self.t2.id]},
|
|
format="json",
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.owner, self.user2)
|
|
self.assertEqual(doc.tags.all().count(), 1)
|
|
self.assertIn(self.t2, doc.tags.all())
|
|
|
|
@override_settings(
|
|
PAPERLESS_EMAIL_HOST="localhost",
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
|
def test_workflow_assignment_then_email_includes_attachment(
|
|
self,
|
|
mock_email_send,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with assignment and email actions
|
|
- Email action configured to include the document
|
|
WHEN:
|
|
- Workflow is run on a newly created document
|
|
THEN:
|
|
- Email action sends the document as an attachment
|
|
"""
|
|
|
|
storage_path = StoragePath.objects.create(
|
|
name="sp2",
|
|
path="workflow/{{ document.pk }}",
|
|
)
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
)
|
|
assignment_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
assign_storage_path=storage_path,
|
|
assign_owner=self.user2,
|
|
)
|
|
assignment_action.assign_tags.add(self.t1)
|
|
|
|
email_action_config = WorkflowActionEmail.objects.create(
|
|
subject="Doc ready {doc_title}",
|
|
body="Document URL: {doc_url}",
|
|
to="owner@example.com",
|
|
include_document=True,
|
|
)
|
|
email_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action_config,
|
|
)
|
|
|
|
workflow = Workflow.objects.create(name="Assignment then email", order=0)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.set([assignment_action, email_action])
|
|
|
|
temp_working_copy = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "working-copy.pdf",
|
|
)
|
|
|
|
Document.objects.create(
|
|
title="workflow doc",
|
|
correspondent=self.c,
|
|
checksum="wf-assignment-email",
|
|
mime_type="application/pdf",
|
|
)
|
|
|
|
consumable_document = ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=temp_working_copy,
|
|
)
|
|
|
|
mock_email_send.return_value = 1
|
|
|
|
with self.assertNoLogs("paperless.workflows", level="ERROR"):
|
|
run_workflows(
|
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
consumable_document,
|
|
overrides=DocumentMetadataOverrides(),
|
|
)
|
|
|
|
mock_email_send.assert_called_once()
|
|
|
|
@override_settings(
|
|
PAPERLESS_EMAIL_HOST="localhost",
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("httpx.post")
|
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
|
def test_workflow_email_action(self, mock_email_send, mock_post) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with email action
|
|
WHEN:
|
|
- Document that matches is updated
|
|
THEN:
|
|
- email is sent
|
|
"""
|
|
mock_post.return_value = mock.Mock(
|
|
status_code=200,
|
|
json=mock.Mock(return_value={"status": "ok"}),
|
|
)
|
|
mock_email_send.return_value = 1
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Test Notification: {doc_title}",
|
|
body="Test message: {doc_url}",
|
|
to="user@example.com",
|
|
include_document=False,
|
|
)
|
|
self.assertEqual(str(email_action), f"Workflow Email Action {email_action.id}")
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
mock_email_send.assert_called_once()
|
|
|
|
@override_settings(
|
|
PAPERLESS_EMAIL_HOST="localhost",
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
|
def test_workflow_email_include_file(self, mock_email_send) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with email action
|
|
- Include document is set to True
|
|
WHEN:
|
|
- Document that matches is updated
|
|
THEN:
|
|
- Notification includes document file
|
|
"""
|
|
|
|
# move the file
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Test Notification: {doc_title}",
|
|
body="Test message: {doc_url}",
|
|
to="me@example.com",
|
|
include_document=True,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
filename=test_file,
|
|
)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
mock_email_send.assert_called_once()
|
|
|
|
mock_email_send.reset_mock()
|
|
# test with .eml file
|
|
test_file2 = shutil.copy(
|
|
self.SAMPLE_DIR / "eml_with_umlaut.eml",
|
|
self.dirs.scratch_dir / "eml_with_umlaut.eml",
|
|
)
|
|
|
|
doc2 = Document.objects.create(
|
|
title="sample eml",
|
|
checksum="123456",
|
|
filename=test_file2,
|
|
mime_type="message/rfc822",
|
|
)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc2)
|
|
|
|
mock_email_send.assert_called_once()
|
|
|
|
@override_settings(
|
|
PAPERLESS_EMAIL_HOST="localhost",
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend",
|
|
)
|
|
def test_workflow_email_attachment_uses_storage_filename(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with include document action
|
|
- Document stored with formatted storage-path filename
|
|
WHEN:
|
|
- Workflow sends an email
|
|
THEN:
|
|
- Attachment filename matches the stored filename
|
|
"""
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Test Notification: {doc_title}",
|
|
body="Test message: {doc_url}",
|
|
to="me@example.com",
|
|
include_document=True,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
)
|
|
workflow = Workflow.objects.create(
|
|
name="Workflow attachment filename",
|
|
order=0,
|
|
)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
workflow.save()
|
|
|
|
storage_path = StoragePath.objects.create(
|
|
name="Fancy Path",
|
|
path="formatted/{{ document.pk }}/{{ title }}",
|
|
)
|
|
doc = Document.objects.create(
|
|
title="workflow doc",
|
|
correspondent=self.c,
|
|
checksum="workflow-email-attachment",
|
|
mime_type="application/pdf",
|
|
storage_path=storage_path,
|
|
original_filename="workflow-orig.pdf",
|
|
)
|
|
|
|
# eg what happens in update_filename_and_move_files
|
|
generated = generate_unique_filename(doc)
|
|
destination = (settings.ORIGINALS_DIR / generated).resolve()
|
|
create_source_path_directory(destination)
|
|
shutil.copy(self.SAMPLE_DIR / "simple.pdf", destination)
|
|
Document.objects.filter(pk=doc.pk).update(filename=generated.as_posix())
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
self.assertEqual(len(mail.outbox), 1)
|
|
attachment_names = [att[0] for att in mail.outbox[0].attachments]
|
|
self.assertEqual(attachment_names, [Path(generated).name])
|
|
|
|
@override_settings(
|
|
EMAIL_ENABLED=False,
|
|
)
|
|
def test_workflow_email_action_no_email_setup(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with email action
|
|
- Email is not enabled
|
|
WHEN:
|
|
- Document that matches is updated
|
|
THEN:
|
|
- Error is logged
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Test Notification: {doc_title}",
|
|
body="Test message: {doc_url}",
|
|
to="me@example.com",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.workflows.actions", level="ERROR") as cm:
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
expected_str = "Email backend has not been configured"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
|
|
@override_settings(
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
|
def test_workflow_email_action_fail(self, mock_email_send) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with email action
|
|
WHEN:
|
|
- Document that matches is updated
|
|
- An error occurs during email send
|
|
THEN:
|
|
- Error is logged
|
|
"""
|
|
mock_email_send.side_effect = Exception("Error occurred sending email")
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Test Notification: {doc_title}",
|
|
body="Test message: {doc_url}",
|
|
to="me@example.com",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.workflows", level="ERROR") as cm:
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
expected_str = "Error occurred sending email"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
|
|
@override_settings(
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
|
def test_workflow_email_action_template_error(self, mock_email_send) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document added workflow with an email action whose body uses an
|
|
undefined template variable, followed by an assignment action
|
|
WHEN:
|
|
- Document consumption finishes
|
|
THEN:
|
|
- Error is logged, consumption is not aborted
|
|
- No email is sent
|
|
- Subsequent actions still run
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Test Notification: {{ doc_title }}",
|
|
body="Document Title: {{ title }}",
|
|
to="me@example.com",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
order=0,
|
|
)
|
|
assignment_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
assign_correspondent=self.c2,
|
|
order=1,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action, assignment_action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.workflows", level="ERROR") as cm:
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
|
|
self.assertIn("'title' is undefined", cm.output[0])
|
|
mock_email_send.assert_not_called()
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.correspondent, self.c2)
|
|
|
|
@override_settings(
|
|
PAPERLESS_EMAIL_HOST="localhost",
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("httpx.post")
|
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_email_consumption_started(
|
|
self,
|
|
mock_email_send,
|
|
mock_post,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with email action and consumption trigger
|
|
WHEN:
|
|
- Document is consumed
|
|
THEN:
|
|
- Email is sent
|
|
"""
|
|
mock_post.return_value = mock.Mock(
|
|
status_code=200,
|
|
json=mock.Mock(return_value={"status": "ok"}),
|
|
)
|
|
mock_email_send.return_value = 1
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Test Notification: {doc_title}",
|
|
body="Test message: {doc_url}",
|
|
to="user@example.com",
|
|
include_document=False,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="INFO"):
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
|
|
mock_email_send.assert_called_once()
|
|
|
|
@override_settings(
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
PAPERLESS_FORCE_SCRIPT_NAME="/paperless",
|
|
BASE_URL="/paperless/",
|
|
)
|
|
@mock.patch("documents.workflows.webhooks.send_webhook.apply_async")
|
|
def test_workflow_webhook_action_body(self, mock_post) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with webhook action which uses body
|
|
WHEN:
|
|
- Document that matches is updated
|
|
THEN:
|
|
- Webhook is sent with body
|
|
"""
|
|
mock_post.return_value = mock.Mock(
|
|
status_code=200,
|
|
json=mock.Mock(return_value={"status": "ok"}),
|
|
)
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
webhook_action = WorkflowActionWebhook.objects.create(
|
|
use_params=False,
|
|
body="Test message: {{doc_url}} with id {{doc_id}}",
|
|
url="http://paperless-ngx.com",
|
|
include_document=False,
|
|
)
|
|
self.assertEqual(
|
|
str(webhook_action),
|
|
f"Workflow Webhook Action {webhook_action.id}",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
|
webhook=webhook_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
mock_post.assert_called_once_with(
|
|
kwargs={
|
|
"url": "http://paperless-ngx.com",
|
|
"data": (
|
|
f"Test message: http://localhost:8000/paperless/documents/{doc.id}/"
|
|
f" with id {doc.id}"
|
|
),
|
|
"headers": {},
|
|
"files": None,
|
|
"as_json": False,
|
|
},
|
|
)
|
|
|
|
@override_settings(
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("documents.workflows.webhooks.send_webhook.apply_async")
|
|
def test_workflow_webhook_action_w_files(self, mock_post) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with webhook action which includes document
|
|
WHEN:
|
|
- Document that matches is updated
|
|
THEN:
|
|
- Webhook is sent with file
|
|
"""
|
|
mock_post.return_value = mock.Mock(
|
|
status_code=200,
|
|
json=mock.Mock(return_value={"status": "ok"}),
|
|
)
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
webhook_action = WorkflowActionWebhook.objects.create(
|
|
use_params=False,
|
|
body="Test message: {{doc_url}}",
|
|
url="http://paperless-ngx.com",
|
|
include_document=True,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
|
webhook=webhook_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="simple.pdf",
|
|
filename=test_file,
|
|
mime_type="application/pdf",
|
|
)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
mock_post.assert_called_once_with(
|
|
kwargs={
|
|
"url": "http://paperless-ngx.com",
|
|
"data": f"Test message: http://localhost:8000/documents/{doc.id}/",
|
|
"headers": {},
|
|
"files": {"file": ("simple.pdf", mock.ANY, "application/pdf")},
|
|
"as_json": False,
|
|
},
|
|
)
|
|
|
|
@mock.patch("documents.signals.handlers.execute_webhook_action")
|
|
def test_workflow_webhook_action_does_not_overwrite_concurrent_tags(
|
|
self,
|
|
mock_execute_webhook_action,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document updated workflow with only a webhook action
|
|
- A tag update that happens after run_workflows
|
|
WHEN:
|
|
- The workflow runs
|
|
THEN:
|
|
- The concurrent tag update is preserved
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
webhook_action = WorkflowActionWebhook.objects.create(
|
|
use_params=False,
|
|
body="Test message: {{doc_url}}",
|
|
url="http://paperless-ngx.com",
|
|
include_document=False,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
|
webhook=webhook_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Webhook workflow",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
inbox_tag = Tag.objects.create(name="inbox")
|
|
error_tag = Tag.objects.create(name="error")
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
doc.tags.add(inbox_tag)
|
|
|
|
def add_error_tag(*args, **kwargs):
|
|
Document.objects.get(pk=doc.pk).tags.add(error_tag)
|
|
|
|
mock_execute_webhook_action.side_effect = add_error_tag
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertCountEqual(doc.tags.all(), [inbox_tag, error_tag])
|
|
|
|
@mock.patch("documents.signals.handlers.execute_webhook_action")
|
|
def test_workflow_tag_actions_do_not_overwrite_concurrent_tags(
|
|
self,
|
|
mock_execute_webhook_action,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document updated workflow that clears tags and assigns an inbox tag
|
|
- A later tag update that happens before the workflow finishes
|
|
WHEN:
|
|
- The workflow runs
|
|
THEN:
|
|
- The later tag update is preserved
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
removal_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOVAL,
|
|
remove_all_tags=True,
|
|
)
|
|
assign_action = WorkflowAction.objects.create(
|
|
assign_owner=self.user2,
|
|
)
|
|
assign_action.assign_tags.add(self.t1)
|
|
webhook_action = WorkflowActionWebhook.objects.create(
|
|
use_params=False,
|
|
body="Test message: {{doc_url}}",
|
|
url="http://paperless-ngx.com",
|
|
include_document=False,
|
|
)
|
|
notify_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
|
webhook=webhook_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow tag race",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(removal_action)
|
|
w.actions.add(assign_action)
|
|
w.actions.add(notify_action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
owner=self.user3,
|
|
)
|
|
doc.tags.add(self.t2, self.t3)
|
|
|
|
def add_error_tag(*args, **kwargs):
|
|
Document.objects.get(pk=doc.pk).tags.add(self.t2)
|
|
|
|
mock_execute_webhook_action.side_effect = add_error_tag
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
doc.refresh_from_db()
|
|
self.assertEqual(doc.owner, self.user2)
|
|
self.assertCountEqual(doc.tags.all(), [self.t1, self.t2])
|
|
|
|
@override_settings(
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
def test_workflow_webhook_action_fail(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with webhook action
|
|
WHEN:
|
|
- Document that matches is updated
|
|
- An error occurs during webhook
|
|
THEN:
|
|
- Error is logged
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
webhook_action = WorkflowActionWebhook.objects.create(
|
|
use_params=True,
|
|
params={
|
|
"title": "Test webhook: {doc_title}",
|
|
"body": "Test message: {doc_url}",
|
|
},
|
|
url="http://paperless-ngx.com",
|
|
include_document=True,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
|
webhook=webhook_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
# fails because no file
|
|
with self.assertLogs("paperless.workflows", level="ERROR") as cm:
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
expected_str = "Error occurred sending webhook"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
|
|
@mock.patch("documents.workflows.webhooks.send_webhook.apply_async")
|
|
def test_workflow_webhook_action_url_invalid_params_headers(
|
|
self,
|
|
mock_post,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with webhook action
|
|
- Invalid params and headers JSON
|
|
WHEN:
|
|
- Document that matches is updated
|
|
THEN:
|
|
- Error is logged
|
|
- The webhook is still queued, with empty data and headers
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
webhook_action = WorkflowActionWebhook.objects.create(
|
|
url="http://paperless-ngx.com",
|
|
use_params=True,
|
|
params="invalid",
|
|
headers="invalid",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
|
webhook=webhook_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.workflows", level="ERROR") as cm:
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
expected_str = "Error occurred parsing webhook params"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
expected_str = "Error occurred parsing webhook headers"
|
|
self.assertIn(expected_str, cm.output[1])
|
|
|
|
mock_post.assert_called_once()
|
|
kwargs = mock_post.call_args.kwargs["kwargs"]
|
|
self.assertEqual(kwargs["data"], {})
|
|
self.assertEqual(kwargs["headers"], {})
|
|
|
|
@mock.patch("httpx.Client.post")
|
|
def test_workflow_webhook_send_webhook_task(self, mock_post) -> None:
|
|
mock_post.return_value = mock.Mock(
|
|
status_code=200,
|
|
json=mock.Mock(return_value={"status": "ok"}),
|
|
raise_for_status=mock.Mock(),
|
|
)
|
|
|
|
with self.assertLogs("paperless.workflows") as cm:
|
|
send_webhook(
|
|
url="http://paperless-ngx.com",
|
|
data="Test message",
|
|
headers={},
|
|
files=None,
|
|
)
|
|
|
|
mock_post.assert_called_once_with(
|
|
url="http://paperless-ngx.com",
|
|
content="Test message",
|
|
headers={},
|
|
files=None,
|
|
)
|
|
|
|
expected_str = "Webhook sent to http://paperless-ngx.com"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
|
|
# with dict
|
|
send_webhook(
|
|
url="http://paperless-ngx.com",
|
|
data={"message": "Test message"},
|
|
headers={},
|
|
files=None,
|
|
)
|
|
mock_post.assert_called_with(
|
|
url="http://paperless-ngx.com",
|
|
data={"message": "Test message"},
|
|
headers={},
|
|
files=None,
|
|
)
|
|
|
|
@mock.patch("httpx.Client.post")
|
|
def test_workflow_webhook_send_webhook_retry(self, mock_http) -> None:
|
|
mock_http.return_value.raise_for_status = mock.Mock(
|
|
side_effect=HTTPStatusError(
|
|
"Error",
|
|
request=mock.Mock(),
|
|
response=mock.Mock(),
|
|
),
|
|
)
|
|
|
|
with self.assertLogs("paperless.workflows") as cm:
|
|
with self.assertRaises(HTTPStatusError):
|
|
send_webhook(
|
|
url="http://paperless-ngx.com",
|
|
data="Test message",
|
|
headers={},
|
|
files=None,
|
|
)
|
|
|
|
self.assertEqual(mock_http.call_count, 1)
|
|
|
|
expected_str = (
|
|
"Failed attempt sending webhook to http://paperless-ngx.com"
|
|
)
|
|
self.assertIn(expected_str, cm.output[0])
|
|
|
|
@mock.patch("documents.workflows.webhooks.send_webhook.apply_async")
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_workflow_webhook_action_consumption(self, mock_post) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with webhook action and consumption trigger
|
|
WHEN:
|
|
- Document is consumed
|
|
THEN:
|
|
- Webhook is sent
|
|
"""
|
|
mock_post.return_value = mock.Mock(
|
|
status_code=200,
|
|
json=mock.Mock(return_value={"status": "ok"}),
|
|
)
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
)
|
|
webhook_action = WorkflowActionWebhook.objects.create(
|
|
use_params=False,
|
|
body="Test message: {doc_url}",
|
|
url="http://paperless-ngx.com",
|
|
include_document=False,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
|
webhook=webhook_action,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.matching", level="INFO"):
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
|
|
mock_post.assert_called_once()
|
|
|
|
@mock.patch("documents.bulk_edit.remove_password")
|
|
def test_password_removal_action_attempts_multiple_passwords(
|
|
self,
|
|
mock_remove_password,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow password removal action
|
|
- Multiple passwords provided
|
|
WHEN:
|
|
- Document updated triggering the workflow
|
|
THEN:
|
|
- Password removal is attempted until one succeeds
|
|
"""
|
|
doc = Document.objects.create(
|
|
title="Protected",
|
|
checksum="pw-checksum",
|
|
)
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
passwords=["wrong", "right", "extra"],
|
|
)
|
|
workflow = Workflow.objects.create(name="Password workflow")
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
|
|
mock_remove_password.side_effect = [
|
|
ValueError("wrong password"),
|
|
"OK",
|
|
]
|
|
|
|
run_workflows(trigger.type, doc)
|
|
|
|
assert mock_remove_password.call_count == 2
|
|
mock_remove_password.assert_has_calls(
|
|
[
|
|
mock.call(
|
|
[doc.id],
|
|
password="wrong",
|
|
update_document=True,
|
|
user=doc.owner,
|
|
source_paths_by_id=None,
|
|
),
|
|
mock.call(
|
|
[doc.id],
|
|
password="right",
|
|
update_document=True,
|
|
user=doc.owner,
|
|
source_paths_by_id=None,
|
|
),
|
|
],
|
|
)
|
|
|
|
@mock.patch("documents.bulk_edit.remove_password")
|
|
def test_password_removal_action_fails_without_correct_password(
|
|
self,
|
|
mock_remove_password,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow password removal action
|
|
- No correct password provided
|
|
WHEN:
|
|
- Document updated triggering the workflow
|
|
THEN:
|
|
- Password removal is attempted for all passwords and fails
|
|
"""
|
|
doc = Document.objects.create(
|
|
title="Protected",
|
|
checksum="pw-checksum-2",
|
|
)
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
passwords=[" ", " "],
|
|
)
|
|
workflow = Workflow.objects.create(name="Password workflow missing passwords")
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
|
|
run_workflows(trigger.type, doc)
|
|
|
|
mock_remove_password.assert_not_called()
|
|
|
|
@mock.patch("documents.bulk_edit.remove_password")
|
|
def test_password_removal_action_skips_without_passwords(
|
|
self,
|
|
mock_remove_password,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow password removal action with no passwords
|
|
WHEN:
|
|
- Workflow is run
|
|
THEN:
|
|
- Password removal is not attempted
|
|
"""
|
|
doc = Document.objects.create(
|
|
title="Protected",
|
|
checksum="pw-checksum-2",
|
|
)
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
passwords="",
|
|
)
|
|
workflow = Workflow.objects.create(name="Password workflow missing passwords")
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
|
|
run_workflows(trigger.type, doc)
|
|
|
|
mock_remove_password.assert_not_called()
|
|
|
|
@mock.patch("documents.bulk_edit.remove_password")
|
|
def test_password_removal_consumable_document_deferred(
|
|
self,
|
|
mock_remove_password,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow password removal action
|
|
- Simulated consumption trigger (a ConsumableDocument is used)
|
|
WHEN:
|
|
- Document consumption is finished
|
|
THEN:
|
|
- Password removal is attempted
|
|
"""
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
passwords=["first", "second"],
|
|
)
|
|
|
|
temp_dir = Path(tempfile.mkdtemp())
|
|
original_file = temp_dir / "file.pdf"
|
|
original_file.write_bytes(b"pdf content")
|
|
consumable = ConsumableDocument(
|
|
source=DocumentSource.ApiUpload,
|
|
original_file=original_file,
|
|
)
|
|
|
|
execute_password_removal_action(action, consumable, logging_group=None)
|
|
|
|
mock_remove_password.assert_not_called()
|
|
|
|
mock_remove_password.side_effect = [
|
|
ValueError("bad password"),
|
|
"OK",
|
|
]
|
|
|
|
doc = Document.objects.create(
|
|
checksum="pw-checksum-consumed",
|
|
title="Protected",
|
|
)
|
|
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
original_file=original_file,
|
|
)
|
|
|
|
assert mock_remove_password.call_count == 2
|
|
mock_remove_password.assert_has_calls(
|
|
[
|
|
mock.call(
|
|
[doc.id],
|
|
password="first",
|
|
update_document=True,
|
|
user=doc.owner,
|
|
source_paths_by_id={doc.id: original_file},
|
|
),
|
|
mock.call(
|
|
[doc.id],
|
|
password="second",
|
|
update_document=True,
|
|
user=doc.owner,
|
|
source_paths_by_id={doc.id: original_file},
|
|
),
|
|
],
|
|
)
|
|
|
|
# ensure handler disconnected after first run
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
assert mock_remove_password.call_count == 2
|
|
|
|
@mock.patch("documents.bulk_edit.remove_password")
|
|
def test_password_removal_document_added_uses_original_file(
|
|
self,
|
|
mock_remove_password,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow password removal action on a DOCUMENT_ADDED trigger
|
|
- run_workflows called with an explicit original_file (staged file
|
|
from the consumer, before the source path is populated)
|
|
WHEN:
|
|
- The workflow runs
|
|
THEN:
|
|
- remove_password is called with source_paths_by_id pointing at the
|
|
staged file rather than the not-yet-existing source_path
|
|
"""
|
|
doc = Document.objects.create(
|
|
title="Protected",
|
|
checksum="pw-checksum-added",
|
|
)
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
|
|
passwords=["secret"],
|
|
)
|
|
workflow = Workflow.objects.create(name="Password workflow added")
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
|
|
mock_remove_password.return_value = "OK"
|
|
|
|
temp_dir = Path(tempfile.mkdtemp())
|
|
original_file = temp_dir / "staged.pdf"
|
|
original_file.write_bytes(b"pdf content")
|
|
|
|
run_workflows(trigger.type, doc, original_file=original_file)
|
|
|
|
mock_remove_password.assert_called_once_with(
|
|
[doc.id],
|
|
password="secret",
|
|
update_document=True,
|
|
user=doc.owner,
|
|
source_paths_by_id={doc.id: original_file},
|
|
)
|
|
|
|
def test_workflow_trash_action_soft_delete(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with delete action
|
|
WHEN:
|
|
- Document that matches is updated
|
|
THEN:
|
|
- Document is moved to trash (soft deleted)
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
self.assertEqual(Document.objects.count(), 1)
|
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
|
|
|
@override_settings(
|
|
PAPERLESS_EMAIL_HOST="localhost",
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
|
def test_workflow_trash_with_email_action(self, mock_email_send) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with email action, then move to trash action
|
|
WHEN:
|
|
- Document matches and workflow runs
|
|
THEN:
|
|
- Email is sent first
|
|
- Document is moved to trash (soft deleted)
|
|
"""
|
|
mock_email_send.return_value = 1
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Document deleted: {doc_title}",
|
|
body="Document {doc_title} will be deleted",
|
|
to="user@example.com",
|
|
include_document=False,
|
|
)
|
|
email_workflow_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
)
|
|
trash_workflow_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow with email then move to trash",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(email_workflow_action, trash_workflow_action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
self.assertEqual(Document.objects.count(), 1)
|
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
mock_email_send.assert_called_once()
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
|
|
|
@override_settings(
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("documents.workflows.webhooks.send_webhook.apply_async")
|
|
def test_workflow_trash_with_webhook_action(self, mock_webhook_delay) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with webhook action (include_document=True), then move to trash action
|
|
WHEN:
|
|
- Document matches and workflow runs
|
|
THEN:
|
|
- Webhook .apply_async() is called with complete data including file bytes
|
|
- Document is moved to trash (soft deleted)
|
|
- Webhook task has all necessary data and doesn't rely on document existence
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
webhook_action = WorkflowActionWebhook.objects.create(
|
|
use_params=True,
|
|
params={
|
|
"title": "{{doc_title}}",
|
|
"message": "Document being deleted",
|
|
},
|
|
url="https://paperless-ngx.com/webhook",
|
|
include_document=True,
|
|
)
|
|
webhook_workflow_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.WEBHOOK,
|
|
webhook=webhook_action,
|
|
)
|
|
trash_workflow_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow with webhook then move to trash",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(webhook_workflow_action, trash_workflow_action)
|
|
w.save()
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="simple.pdf",
|
|
filename=test_file,
|
|
mime_type="application/pdf",
|
|
)
|
|
|
|
self.assertEqual(Document.objects.count(), 1)
|
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
|
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
mock_webhook_delay.assert_called_once()
|
|
call_kwargs = mock_webhook_delay.call_args[1]["kwargs"]
|
|
self.assertEqual(call_kwargs["url"], "https://paperless-ngx.com/webhook")
|
|
self.assertEqual(
|
|
call_kwargs["data"],
|
|
{"title": "sample test", "message": "Document being deleted"},
|
|
)
|
|
self.assertIsNotNone(call_kwargs["files"])
|
|
self.assertIn("file", call_kwargs["files"])
|
|
self.assertEqual(call_kwargs["files"]["file"][0], "simple.pdf")
|
|
self.assertEqual(call_kwargs["files"]["file"][2], "application/pdf")
|
|
self.assertIsInstance(call_kwargs["files"]["file"][1], bytes)
|
|
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
|
|
|
@override_settings(
|
|
PAPERLESS_EMAIL_HOST="localhost",
|
|
EMAIL_ENABLED=True,
|
|
PAPERLESS_URL="http://localhost:8000",
|
|
)
|
|
@mock.patch("django.core.mail.message.EmailMessage.send")
|
|
def test_workflow_trash_after_email_failure(self, mock_email_send) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with email action (that fails), then move to trash action
|
|
WHEN:
|
|
- Document matches and workflow runs
|
|
- Email action raises exception
|
|
THEN:
|
|
- Email failure is logged
|
|
- Move to Trash still executes successfully (soft delete)
|
|
"""
|
|
mock_email_send.side_effect = Exception("Email server error")
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
email_action = WorkflowActionEmail.objects.create(
|
|
subject="Document deleted: {doc_title}",
|
|
body="Document {doc_title} will be deleted",
|
|
to="user@example.com",
|
|
include_document=False,
|
|
)
|
|
email_workflow_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.EMAIL,
|
|
email=email_action,
|
|
)
|
|
trash_workflow_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow with failing email then move to trash",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(email_workflow_action, trash_workflow_action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
self.assertEqual(Document.objects.count(), 1)
|
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
|
|
|
with self.assertLogs("paperless.workflows.actions", level="ERROR") as cm:
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
expected_str = "Error occurred sending notification email"
|
|
self.assertIn(expected_str, cm.output[0])
|
|
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
|
|
|
def test_multiple_workflows_trash_then_assignment(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow 1 (order=0) with move to trash action
|
|
- Workflow 2 (order=1) with assignment action
|
|
- Both workflows match the same document
|
|
WHEN:
|
|
- Workflows run sequentially
|
|
THEN:
|
|
- First workflow runs and deletes document (soft delete)
|
|
- Second workflow does not trigger (document no longer exists)
|
|
- Logs confirm move to trash and skipping of remaining workflows
|
|
"""
|
|
trigger1 = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
trash_workflow_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
|
)
|
|
w1 = Workflow.objects.create(
|
|
name="Workflow 1 - Move to Trash",
|
|
order=0,
|
|
)
|
|
w1.triggers.add(trigger1)
|
|
w1.actions.add(trash_workflow_action)
|
|
w1.save()
|
|
|
|
trigger2 = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
assignment_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
assign_correspondent=self.c2,
|
|
)
|
|
w2 = Workflow.objects.create(
|
|
name="Workflow 2 - Assignment",
|
|
order=1,
|
|
)
|
|
w2.triggers.add(trigger2)
|
|
w2.actions.add(assignment_action)
|
|
w2.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=self.c,
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
self.assertEqual(Document.objects.count(), 1)
|
|
self.assertEqual(Document.deleted_objects.count(), 0)
|
|
|
|
with self.assertLogs("paperless", level="DEBUG") as cm:
|
|
run_workflows(WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED, doc)
|
|
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
self.assertEqual(Document.deleted_objects.count(), 1)
|
|
|
|
# We check logs instead of WorkflowRun.objects.count() because when the document
|
|
# is soft-deleted, the WorkflowRun is cascade-deleted (hard delete) since it does
|
|
# not inherit from the SoftDeleteModel. The logs confirm that the first workflow
|
|
# executed the move to trash and remaining workflows were skipped.
|
|
log_output = "\n".join(cm.output)
|
|
self.assertIn("Moved document", log_output)
|
|
self.assertIn("to trash", log_output)
|
|
self.assertIn(
|
|
"Document was moved to trash, skipping remaining workflows",
|
|
log_output,
|
|
)
|
|
|
|
def test_workflow_delete_action_during_consumption(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with consumption trigger and delete action
|
|
WHEN:
|
|
- Document is being consumed and workflow runs
|
|
THEN:
|
|
- StopConsumeTaskError is raised to halt consumption
|
|
- Original file is deleted
|
|
- No document is created
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ConsumeFolder}",
|
|
filter_filename="*",
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow Delete During Consumption",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
# Create a test file to be consumed
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
test_file_path = Path(test_file)
|
|
self.assertTrue(test_file_path.exists())
|
|
|
|
# Create a ConsumableDocument
|
|
consumable_doc = ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file_path,
|
|
)
|
|
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
|
|
# Run workflows with overrides (consumption flow)
|
|
with self.assertRaises(StopConsumeTaskError) as context:
|
|
run_workflows(
|
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
consumable_doc,
|
|
overrides=DocumentMetadataOverrides(),
|
|
)
|
|
|
|
self.assertIn("deleted by workflow action", str(context.exception))
|
|
|
|
# File should be deleted
|
|
self.assertFalse(test_file_path.exists())
|
|
|
|
# No document should be created
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
|
|
def test_workflow_delete_action_during_consumption_with_assignment(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Workflow with consumption trigger, assignment action, then delete action
|
|
WHEN:
|
|
- Document is being consumed and workflow runs
|
|
THEN:
|
|
- StopConsumeTaskError is raised to halt consumption
|
|
- Original file is deleted
|
|
- No document is created (even though assignment would have worked)
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ConsumeFolder}",
|
|
filter_filename="*",
|
|
)
|
|
assignment_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.ASSIGNMENT,
|
|
assign_title="This should not be applied",
|
|
assign_correspondent=self.c,
|
|
)
|
|
trash_workflow_action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.MOVE_TO_TRASH,
|
|
)
|
|
w = Workflow.objects.create(
|
|
name="Workflow Assignment then Delete During Consumption",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(assignment_action, trash_workflow_action)
|
|
w.save()
|
|
|
|
# Create a test file to be consumed
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple2.pdf",
|
|
)
|
|
test_file_path = Path(test_file)
|
|
self.assertTrue(test_file_path.exists())
|
|
|
|
# Create a ConsumableDocument
|
|
consumable_doc = ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file_path,
|
|
)
|
|
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
|
|
# Run workflows with overrides (consumption flow)
|
|
with self.assertRaises(StopConsumeTaskError):
|
|
run_workflows(
|
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
consumable_doc,
|
|
overrides=DocumentMetadataOverrides(),
|
|
)
|
|
|
|
# File should be deleted
|
|
self.assertFalse(test_file_path.exists())
|
|
|
|
# No document should be created
|
|
self.assertEqual(Document.objects.count(), 0)
|
|
|
|
|
|
class TestWebhookSend:
|
|
def test_send_webhook_data_or_json(
|
|
self,
|
|
httpx_mock: HTTPXMock,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Nothing
|
|
WHEN:
|
|
- send_webhook is called with data or dict
|
|
THEN:
|
|
- data is sent as form-encoded and json, respectively
|
|
"""
|
|
httpx_mock.add_response(
|
|
content=b"ok",
|
|
)
|
|
|
|
send_webhook(
|
|
url="http://paperless-ngx.com",
|
|
data="Test message",
|
|
headers={},
|
|
files=None,
|
|
as_json=False,
|
|
)
|
|
assert httpx_mock.get_request().headers.get("Content-Type") is None
|
|
httpx_mock.reset()
|
|
|
|
httpx_mock.add_response(
|
|
json={"status": "ok"},
|
|
)
|
|
send_webhook(
|
|
url="http://paperless-ngx.com",
|
|
data={"message": "Test message"},
|
|
headers={},
|
|
files=None,
|
|
as_json=True,
|
|
)
|
|
assert httpx_mock.get_request().headers["Content-Type"] == "application/json"
|
|
|
|
|
|
@pytest.fixture
|
|
def resolve_to(monkeypatch: pytest.MonkeyPatch) -> Callable[[str], None]:
|
|
"""
|
|
Force DNS resolution to a specific IP for any hostname.
|
|
"""
|
|
|
|
def _set(ip: str) -> None:
|
|
def fake_getaddrinfo(
|
|
host: str,
|
|
*_args: object,
|
|
**_kwargs: object,
|
|
) -> list[tuple[Any, ...]]:
|
|
return [(socket.AF_INET, None, None, "", (ip, 0))]
|
|
|
|
monkeypatch.setattr(socket, "getaddrinfo", fake_getaddrinfo)
|
|
|
|
return _set
|
|
|
|
|
|
class TestWebhookSecurity:
|
|
def test_blocks_invalid_scheme_or_hostname(self, httpx_mock: HTTPXMock) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Invalid URL schemes or hostnames
|
|
WHEN:
|
|
- send_webhook is called with such URLs
|
|
THEN:
|
|
- ValueError is raised
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
send_webhook(
|
|
"ftp://example.com",
|
|
data="",
|
|
headers={},
|
|
files=None,
|
|
as_json=False,
|
|
)
|
|
|
|
with pytest.raises(ValueError):
|
|
send_webhook(
|
|
"http:///nohost",
|
|
data="",
|
|
headers={},
|
|
files=None,
|
|
as_json=False,
|
|
)
|
|
|
|
@override_settings(WEBHOOKS_ALLOWED_PORTS=[80, 443])
|
|
def test_blocks_disallowed_port(self, httpx_mock: HTTPXMock) -> None:
|
|
"""
|
|
GIVEN:
|
|
- URL with a disallowed port
|
|
WHEN:
|
|
- send_webhook is called with such URL
|
|
THEN:
|
|
- ValueError is raised
|
|
"""
|
|
with pytest.raises(ValueError):
|
|
send_webhook(
|
|
"http://paperless-ngx.com:8080",
|
|
data="",
|
|
headers={},
|
|
files=None,
|
|
as_json=False,
|
|
)
|
|
|
|
assert httpx_mock.get_request() is None
|
|
|
|
@override_settings(WEBHOOKS_ALLOW_INTERNAL_REQUESTS=False)
|
|
def test_blocks_private_loopback_linklocal(
|
|
self,
|
|
httpx_mock: HTTPXMock,
|
|
resolve_to,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- URL with a private, loopback, or link-local IP address
|
|
- WEBHOOKS_ALLOW_INTERNAL_REQUESTS is False
|
|
WHEN:
|
|
- send_webhook is called with such URL
|
|
THEN:
|
|
- ValueError is raised
|
|
"""
|
|
resolve_to("127.0.0.1")
|
|
with pytest.raises(ConnectError):
|
|
send_webhook(
|
|
"http://paperless-ngx.com",
|
|
data="",
|
|
headers={},
|
|
files=None,
|
|
as_json=False,
|
|
)
|
|
|
|
def test_allows_public_ip_and_sends(
|
|
self,
|
|
httpx_mock: HTTPXMock,
|
|
resolve_to,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- URL with a public IP address
|
|
WHEN:
|
|
- send_webhook is called with such URL
|
|
THEN:
|
|
- Request is sent successfully
|
|
"""
|
|
resolve_to("52.207.186.75")
|
|
httpx_mock.add_response(content=b"ok")
|
|
|
|
send_webhook(
|
|
url="http://paperless-ngx.com",
|
|
data="hi",
|
|
headers={},
|
|
files=None,
|
|
as_json=False,
|
|
)
|
|
|
|
req = httpx_mock.get_request()
|
|
assert req.url.host == "52.207.186.75"
|
|
assert req.headers["host"] == "paperless-ngx.com"
|
|
|
|
def test_follow_redirects_disabled(self, httpx_mock: HTTPXMock, resolve_to) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A URL that redirects
|
|
WHEN:
|
|
- send_webhook is called with follow_redirects=False
|
|
THEN:
|
|
- Request is made to the original URL and does not follow the redirect
|
|
"""
|
|
resolve_to("52.207.186.75")
|
|
# Return a redirect and ensure we don't follow it (only one request recorded)
|
|
httpx_mock.add_response(
|
|
status_code=302,
|
|
headers={"location": "http://internal-service.local"},
|
|
content=b"",
|
|
)
|
|
|
|
with pytest.raises(HTTPError):
|
|
send_webhook(
|
|
"http://paperless-ngx.com",
|
|
data="",
|
|
headers={},
|
|
files=None,
|
|
as_json=False,
|
|
)
|
|
|
|
assert len(httpx_mock.get_requests()) == 1
|
|
|
|
def test_strips_user_supplied_host_header(
|
|
self,
|
|
httpx_mock: HTTPXMock,
|
|
resolve_to: Callable[[str], None],
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A URL with a user-supplied Host header
|
|
WHEN:
|
|
- send_webhook is called with a malicious Host header
|
|
THEN:
|
|
- The Host header is stripped and replaced with the resolved hostname
|
|
"""
|
|
resolve_to("52.207.186.75")
|
|
httpx_mock.add_response(content=b"ok")
|
|
|
|
send_webhook(
|
|
url="http://paperless-ngx.com",
|
|
data="ok",
|
|
headers={"Host": "evil.test"},
|
|
files=None,
|
|
as_json=False,
|
|
)
|
|
|
|
req = httpx_mock.get_request()
|
|
assert req.headers["Host"] == "paperless-ngx.com"
|
|
assert "evil.test" not in req.headers.get("Host", "")
|
|
|
|
|
|
@pytest.mark.django_db
|
|
@pytest.mark.usefixtures("_search_index")
|
|
class TestDateWorkflowLocalization(
|
|
SampleDirMixin,
|
|
):
|
|
"""Test cases for workflows that use date localization in templates."""
|
|
|
|
TEST_DATETIME = datetime.datetime(
|
|
2023,
|
|
6,
|
|
26,
|
|
14,
|
|
30,
|
|
5,
|
|
tzinfo=datetime.UTC,
|
|
)
|
|
|
|
@pytest.mark.parametrize(
|
|
"title_template,expected_title",
|
|
[
|
|
pytest.param(
|
|
"Created at {{ created | localize_date('MMMM', 'es_ES') }}",
|
|
"Created at junio",
|
|
id="spanish_month",
|
|
),
|
|
pytest.param(
|
|
"Created at {{ created | localize_date('MMMM', 'de_DE') }}",
|
|
"Created at Juni", # codespell:ignore
|
|
id="german_month",
|
|
),
|
|
pytest.param(
|
|
"Created at {{ created | localize_date('dd/MM/yyyy', 'en_GB') }}",
|
|
"Created at 26/06/2023",
|
|
id="british_date_format",
|
|
),
|
|
],
|
|
)
|
|
def test_document_added_workflow_localization(
|
|
self,
|
|
title_template: str,
|
|
expected_title: str,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document added workflow with title template using localize_date filter
|
|
WHEN:
|
|
- Document is consumed
|
|
THEN:
|
|
- Document title is set with localized date
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
filter_filename="*sample*",
|
|
)
|
|
|
|
action = WorkflowAction.objects.create(
|
|
assign_title=title_template,
|
|
)
|
|
|
|
workflow = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
workflow.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=None,
|
|
original_filename="sample.pdf",
|
|
created=self.TEST_DATETIME,
|
|
)
|
|
|
|
document_consumption_finished.send(
|
|
sender=self.__class__,
|
|
document=doc,
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
assert doc.title == expected_title
|
|
|
|
@pytest.mark.parametrize(
|
|
"title_template,expected_title",
|
|
[
|
|
pytest.param(
|
|
"Created at {{ created | localize_date('MMMM', 'es_ES') }}",
|
|
"Created at junio",
|
|
id="spanish_month",
|
|
),
|
|
pytest.param(
|
|
"Created at {{ created | localize_date('MMMM', 'de_DE') }}",
|
|
"Created at Juni", # codespell:ignore
|
|
id="german_month",
|
|
),
|
|
pytest.param(
|
|
"Created at {{ created | localize_date('dd/MM/yyyy', 'en_GB') }}",
|
|
"Created at 26/06/2023",
|
|
id="british_date_format",
|
|
),
|
|
],
|
|
)
|
|
def test_document_updated_workflow_localization(
|
|
self,
|
|
title_template: str,
|
|
expected_title: str,
|
|
) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Document updated workflow with title template using localize_date filter
|
|
WHEN:
|
|
- Document is updated via API
|
|
THEN:
|
|
- Document title is set with localized date
|
|
"""
|
|
# Setup test data
|
|
dt = DocumentType.objects.create(name="DocType Name")
|
|
c = Correspondent.objects.create(name="Correspondent Name")
|
|
|
|
client = APIClient()
|
|
superuser = UserFactory(username="superuser", superuser=True)
|
|
client.force_authenticate(user=superuser)
|
|
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
filter_has_document_type=dt,
|
|
)
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
correspondent=c,
|
|
original_filename="sample.pdf",
|
|
created=self.TEST_DATETIME,
|
|
)
|
|
|
|
action = WorkflowAction.objects.create(
|
|
assign_title=title_template,
|
|
)
|
|
|
|
workflow = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
workflow.triggers.add(trigger)
|
|
workflow.actions.add(action)
|
|
workflow.save()
|
|
|
|
client.patch(
|
|
f"/api/documents/{doc.id}/",
|
|
{"document_type": dt.id},
|
|
format="json",
|
|
)
|
|
|
|
doc.refresh_from_db()
|
|
assert doc.title == expected_title
|
|
|
|
@pytest.mark.parametrize(
|
|
"title_template,expected_title",
|
|
[
|
|
pytest.param(
|
|
"Added at {{ added | localize_date('MMMM', 'es_ES') }}",
|
|
"Added at junio",
|
|
id="spanish_month",
|
|
),
|
|
pytest.param(
|
|
"Added at {{ added | localize_date('MMMM', 'de_DE') }}",
|
|
"Added at Juni", # codespell:ignore
|
|
id="german_month",
|
|
),
|
|
pytest.param(
|
|
"Added at {{ added | localize_date('dd/MM/yyyy', 'en_GB') }}",
|
|
"Added at 26/06/2023",
|
|
id="british_date_format",
|
|
),
|
|
],
|
|
)
|
|
@pytest.mark.usefixtures("fake_progress_manager")
|
|
def test_document_consumption_workflow_localization(
|
|
self,
|
|
tmp_path: Path,
|
|
settings: Settings,
|
|
title_template: str,
|
|
expected_title: str,
|
|
) -> None:
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
sources=f"{DocumentSource.ApiUpload}",
|
|
filter_filename="simple*",
|
|
)
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
tmp_path / "simple.pdf",
|
|
)
|
|
|
|
action = WorkflowAction.objects.create(
|
|
assign_title=title_template,
|
|
)
|
|
|
|
w = Workflow.objects.create(
|
|
name="Workflow 1",
|
|
order=0,
|
|
)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
(tmp_path / "scratch").mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "thumbnails").mkdir(parents=True, exist_ok=True)
|
|
|
|
# Temporarily override "now" for the environment so templates using
|
|
# added/created placeholders behave as if it's a different system date.
|
|
with (
|
|
mock.patch(
|
|
"django.utils.timezone.now",
|
|
return_value=self.TEST_DATETIME,
|
|
),
|
|
override_settings(
|
|
SCRATCH_DIR=tmp_path / "scratch",
|
|
THUMBNAIL_DIR=tmp_path / "thumbnails",
|
|
),
|
|
):
|
|
tasks.consume_file(
|
|
ConsumableDocument(
|
|
source=DocumentSource.ApiUpload,
|
|
original_file=test_file,
|
|
),
|
|
None,
|
|
)
|
|
document = Document.objects.first()
|
|
assert document is not None
|
|
assert document.title == expected_title
|
|
|
|
|
|
class TestRemoteOCRWorkflowAction(DirectoriesMixin, SampleDirMixin, APITestCase):
|
|
def _make_workflow(self, trigger_type) -> None:
|
|
trigger = WorkflowTrigger.objects.create(type=trigger_type)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
)
|
|
w = Workflow.objects.create(name="Remote OCR", order=0)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
def test_consumption_trigger_requests_remote_ocr(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A consumption workflow with a remote OCR action
|
|
WHEN:
|
|
- A matching document is consumed
|
|
THEN:
|
|
- The overrides ask for remote OCR, which is what the consumer
|
|
reads when choosing a parser
|
|
"""
|
|
self._make_workflow(WorkflowTrigger.WorkflowTriggerType.CONSUMPTION)
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
overrides = DocumentMetadataOverrides()
|
|
|
|
run_workflows(
|
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
overrides=overrides,
|
|
)
|
|
|
|
self.assertTrue(overrides.remote_ocr)
|
|
|
|
def test_other_trigger_types_are_ignored(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A workflow with a remote OCR action that also has a
|
|
non-consumption trigger, which is a valid combination
|
|
WHEN:
|
|
- The non-consumption trigger fires
|
|
THEN:
|
|
- The action is skipped, since the document has already been
|
|
parsed by this point
|
|
"""
|
|
trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
)
|
|
updated_trigger = WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
)
|
|
action = WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
|
)
|
|
w = Workflow.objects.create(name="Remote OCR", order=0)
|
|
w.triggers.add(trigger, updated_trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
|
|
doc = Document.objects.create(
|
|
title="sample test",
|
|
original_filename="sample.pdf",
|
|
)
|
|
|
|
with self.assertLogs("paperless.handlers", level="DEBUG") as cm:
|
|
run_workflows(
|
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
|
doc,
|
|
)
|
|
|
|
self.assertIn("only applies to consumption triggers", "".join(cm.output))
|
|
|
|
|
|
SUGGESTIONS: ClassificationSuggestions = {
|
|
"title": "Suggested Title",
|
|
"tags": {
|
|
"existing_ids": [],
|
|
"new_names": ["Existing Tag", "Suggested Tag"],
|
|
},
|
|
"correspondents": {
|
|
"existing_ids": [],
|
|
"new_names": ["Existing Correspondent", "Suggested Correspondent"],
|
|
},
|
|
"document_types": {
|
|
"existing_ids": [],
|
|
"new_names": ["Suggested Document Type"],
|
|
},
|
|
"storage_paths": {
|
|
"existing_ids": [],
|
|
"new_names": ["Suggested Storage Path"],
|
|
},
|
|
"dates": ["2024-03-05"],
|
|
}
|
|
|
|
ALL_SUGGESTION_FIELDS = [
|
|
WorkflowAction.AISuggestionField.TITLE,
|
|
WorkflowAction.AISuggestionField.TAGS,
|
|
WorkflowAction.AISuggestionField.CORRESPONDENT,
|
|
WorkflowAction.AISuggestionField.DOCUMENT_TYPE,
|
|
WorkflowAction.AISuggestionField.STORAGE_PATH,
|
|
WorkflowAction.AISuggestionField.CREATED,
|
|
]
|
|
|
|
|
|
@override_settings(AI_ENABLED=True)
|
|
class TestApplyAISuggestionsWorkflowAction(
|
|
DirectoriesMixin,
|
|
SampleDirMixin,
|
|
APITestCase,
|
|
):
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
self.user = User.objects.create(username="ai-user")
|
|
self.doc = Document.objects.create(
|
|
title="original.pdf",
|
|
content="the document content",
|
|
checksum="ai-suggestions-checksum",
|
|
mime_type="application/pdf",
|
|
created=datetime.date(2020, 1, 1),
|
|
owner=self.user,
|
|
)
|
|
|
|
def make_action(self, **kwargs) -> WorkflowAction:
|
|
return WorkflowAction.objects.create(
|
|
type=WorkflowAction.WorkflowActionType.APPLY_AI_SUGGESTIONS,
|
|
ai_suggestion_fields=kwargs.pop(
|
|
"ai_suggestion_fields",
|
|
ALL_SUGGESTION_FIELDS,
|
|
),
|
|
**kwargs,
|
|
)
|
|
|
|
def make_workflow(self, action: WorkflowAction, trigger_type) -> Workflow:
|
|
trigger = WorkflowTrigger.objects.create(type=trigger_type)
|
|
w = Workflow.objects.create(name="Apply AI suggestions", order=0)
|
|
w.triggers.add(trigger)
|
|
w.actions.add(action)
|
|
w.save()
|
|
return w
|
|
|
|
def apply(
|
|
self,
|
|
action: WorkflowAction,
|
|
suggestions: ClassificationSuggestions = SUGGESTIONS,
|
|
) -> list[str]:
|
|
with mock.patch(
|
|
"documents.workflows.ai.get_ai_document_classification",
|
|
return_value=suggestions,
|
|
):
|
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
|
self.doc.refresh_from_db()
|
|
return changed
|
|
|
|
def test_fields_persist_when_tags_are_applied_in_the_same_run(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document that already has a filename, as any consumed document does
|
|
- Suggestions carrying both a document type and tags
|
|
WHEN:
|
|
- The suggestions are applied
|
|
THEN:
|
|
- The document type is still set after the tags are added
|
|
|
|
Adding tags fires m2m_changed, and update_filename_and_move_files
|
|
refreshes the document from the database. Assigning fields and then
|
|
adding tags before saving loses those assignments, and only for
|
|
documents with a filename, so it does not reproduce on a bare
|
|
Document.objects.create().
|
|
"""
|
|
self.doc.filename = "originals/original.pdf"
|
|
self.doc.save(update_fields=["filename"])
|
|
|
|
action = self.make_action(ai_create_missing=True)
|
|
changed = self.apply(action)
|
|
|
|
self.assertIn("document_type", changed)
|
|
self.assertIn("tags", changed)
|
|
self.assertIsNotNone(
|
|
self.doc.document_type,
|
|
"document_type was reported as applied but did not persist",
|
|
)
|
|
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
|
|
self.assertEqual(self.doc.correspondent.name, "Existing Correspondent")
|
|
self.assertCountEqual(
|
|
[t.name for t in self.doc.tags.all()],
|
|
["Existing Tag", "Suggested Tag"],
|
|
)
|
|
|
|
def test_document_added_trigger_queues_task(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document added workflow with an apply AI suggestions action
|
|
WHEN:
|
|
- A matching document is added
|
|
THEN:
|
|
- The work is queued rather than run inline, so a slow LLM query
|
|
cannot stall the rest of the workflow run
|
|
"""
|
|
action = self.make_action()
|
|
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
|
|
|
with (
|
|
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
|
self.captureOnCommitCallbacks(execute=True),
|
|
):
|
|
run_workflows(
|
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
self.doc,
|
|
)
|
|
delay.assert_not_called()
|
|
|
|
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
|
|
|
def test_consumption_trigger_is_ignored(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A workflow with an apply AI suggestions action and a consumption
|
|
trigger alongside a valid one
|
|
WHEN:
|
|
- The consumption trigger fires
|
|
THEN:
|
|
- The action is skipped, since the document has not been parsed
|
|
yet and so has no content to make suggestions from
|
|
"""
|
|
action = self.make_action()
|
|
w = self.make_workflow(
|
|
action,
|
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
|
)
|
|
w.triggers.add(
|
|
WorkflowTrigger.objects.create(
|
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
),
|
|
)
|
|
|
|
test_file = shutil.copy(
|
|
self.SAMPLE_DIR / "simple.pdf",
|
|
self.dirs.scratch_dir / "simple.pdf",
|
|
)
|
|
|
|
with (
|
|
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
|
self.assertLogs("paperless.handlers", level="DEBUG") as cm,
|
|
):
|
|
run_workflows(
|
|
WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
|
ConsumableDocument(
|
|
source=DocumentSource.ConsumeFolder,
|
|
original_file=test_file,
|
|
),
|
|
overrides=DocumentMetadataOverrides(),
|
|
)
|
|
|
|
delay.assert_not_called()
|
|
self.assertIn("does not apply to consumption triggers", "".join(cm.output))
|
|
|
|
def test_no_selected_fields_does_nothing(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An action with no suggestion fields selected
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- Nothing is changed and it is logged
|
|
"""
|
|
action = self.make_action(ai_suggestion_fields=[])
|
|
|
|
with self.assertLogs("paperless.workflows.ai", level="WARNING") as cm:
|
|
changed = self.apply(action)
|
|
|
|
self.assertEqual(changed, [])
|
|
self.assertIn("no AI suggestion fields selected", "".join(cm.output))
|
|
|
|
@override_settings(AI_ENABLED=False)
|
|
def test_ai_disabled_does_nothing(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An action on an install where AI has since been disabled
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- Nothing is changed and it is logged
|
|
"""
|
|
action = self.make_action()
|
|
|
|
with self.assertLogs("paperless.workflows.ai", level="ERROR") as cm:
|
|
changed = self.apply(action)
|
|
|
|
self.assertEqual(changed, [])
|
|
self.assertIn("AI is not enabled", "".join(cm.output))
|
|
|
|
def test_document_without_content_does_nothing(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document whose OCR content is empty or whitespace-only
|
|
WHEN:
|
|
- AI suggestions are applied by a workflow
|
|
THEN:
|
|
- The classifier is not called and the document is left unchanged
|
|
"""
|
|
action = self.make_action(ai_overwrite_existing=True)
|
|
|
|
for content in ("", " \n\t"):
|
|
with self.subTest(content=content):
|
|
self.doc.content = content
|
|
self.doc.save(update_fields=["content"])
|
|
|
|
with (
|
|
mock.patch(
|
|
"documents.workflows.ai.get_ai_document_classification",
|
|
) as get_classification,
|
|
self.assertLogs(
|
|
"paperless.workflows.ai",
|
|
level="WARNING",
|
|
) as cm,
|
|
):
|
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
|
|
|
self.assertEqual(changed, [])
|
|
get_classification.assert_not_called()
|
|
self.assertIn("has no content", "".join(cm.output))
|
|
self.doc.refresh_from_db()
|
|
self.assertEqual(self.doc.title, "original.pdf")
|
|
|
|
def test_invalid_configuration_leaves_document_untouched(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An AI backend that is misconfigured
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- The failure is logged and the document is left alone. It is not
|
|
re-raised, because retrying will not fix a bad configuration
|
|
"""
|
|
action = self.make_action()
|
|
|
|
with (
|
|
mock.patch(
|
|
"documents.workflows.ai.get_ai_document_classification",
|
|
side_effect=ValueError("nope"),
|
|
),
|
|
self.assertLogs("paperless.workflows.ai", level="ERROR") as cm,
|
|
):
|
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
|
|
|
self.assertEqual(changed, [])
|
|
self.doc.refresh_from_db()
|
|
self.assertEqual(self.doc.title, "original.pdf")
|
|
self.assertIn("Invalid AI configuration", "".join(cm.output))
|
|
|
|
def test_transient_llm_failure_is_raised_for_retry(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An LLM backend that times out, or rate limits the request
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- The error propagates so the queued task can back off and retry,
|
|
rather than silently dropping this document's suggestions
|
|
"""
|
|
action = self.make_action()
|
|
|
|
with (
|
|
mock.patch(
|
|
"documents.workflows.ai.get_ai_document_classification",
|
|
side_effect=LLMTimeoutError(),
|
|
),
|
|
self.assertRaises(LLMTimeoutError),
|
|
):
|
|
apply_ai_suggestions_to_document(action, self.doc)
|
|
|
|
self.doc.refresh_from_db()
|
|
self.assertEqual(self.doc.title, "original.pdf")
|
|
|
|
def test_only_matching_objects_are_applied(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An action without create missing, and only some of the suggested
|
|
objects existing
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- Only the existing objects are assigned, unmatched suggestions are
|
|
dropped rather than creating anything
|
|
"""
|
|
tag = Tag.objects.create(name="Existing Tag", owner=self.user)
|
|
correspondent = Correspondent.objects.create(
|
|
name="Existing Correspondent",
|
|
owner=self.user,
|
|
)
|
|
action = self.make_action(ai_overwrite_existing=True)
|
|
|
|
changed = self.apply(action)
|
|
|
|
self.assertEqual(self.doc.correspondent, correspondent)
|
|
self.assertEqual(list(self.doc.tags.all()), [tag])
|
|
# Nothing matched for these and create missing is off
|
|
self.assertIsNone(self.doc.document_type)
|
|
self.assertIsNone(self.doc.storage_path)
|
|
self.assertNotIn("document_type", changed)
|
|
self.assertEqual(Tag.objects.count(), 1)
|
|
self.assertEqual(Correspondent.objects.count(), 1)
|
|
|
|
def test_existing_id_suggestions_are_applied(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- AI suggestions that select existing taxonomy candidates by ID
|
|
WHEN:
|
|
- The suggestions are applied
|
|
THEN:
|
|
- Each selected object is assigned to the document
|
|
"""
|
|
tag = Tag.objects.create(name="Existing Tag", owner=self.user)
|
|
correspondent = Correspondent.objects.create(
|
|
name="Existing Correspondent",
|
|
owner=self.user,
|
|
)
|
|
document_type = DocumentType.objects.create(
|
|
name="Existing Document Type",
|
|
owner=self.user,
|
|
)
|
|
storage_path = StoragePath.objects.create(
|
|
name="Existing Storage Path",
|
|
path="{{ title }}",
|
|
owner=self.user,
|
|
)
|
|
action = self.make_action(ai_overwrite_existing=True)
|
|
suggestions: ClassificationSuggestions = {
|
|
**SUGGESTIONS,
|
|
"tags": {"existing_ids": [tag.pk], "new_names": []},
|
|
"correspondents": {
|
|
"existing_ids": [correspondent.pk],
|
|
"new_names": [],
|
|
},
|
|
"document_types": {
|
|
"existing_ids": [document_type.pk],
|
|
"new_names": [],
|
|
},
|
|
"storage_paths": {
|
|
"existing_ids": [storage_path.pk],
|
|
"new_names": [],
|
|
},
|
|
}
|
|
|
|
changed = self.apply(action, suggestions)
|
|
|
|
self.assertEqual(list(self.doc.tags.all()), [tag])
|
|
self.assertEqual(self.doc.correspondent, correspondent)
|
|
self.assertEqual(self.doc.document_type, document_type)
|
|
self.assertEqual(self.doc.storage_path, storage_path)
|
|
self.assertTrue(
|
|
{"tags", "correspondent", "document_type", "storage_path"} <= set(changed),
|
|
)
|
|
|
|
def test_create_missing_creates_objects_owned_by_document_owner(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An action with create missing enabled
|
|
WHEN:
|
|
- The action is applied and suggestions match nothing
|
|
THEN:
|
|
- Tags, correspondents and document types are created, owned by the
|
|
document owner so they stay private to them
|
|
- Storage paths are never created, since a path template cannot be
|
|
inferred from a name
|
|
"""
|
|
action = self.make_action(
|
|
ai_create_missing=True,
|
|
ai_overwrite_existing=True,
|
|
)
|
|
|
|
changed = self.apply(action)
|
|
|
|
self.assertEqual(
|
|
sorted(t.name for t in self.doc.tags.all()),
|
|
["Existing Tag", "Suggested Tag"],
|
|
)
|
|
self.assertEqual(self.doc.correspondent.name, "Existing Correspondent")
|
|
self.assertEqual(self.doc.correspondent.owner, self.user)
|
|
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
|
|
self.assertEqual(self.doc.document_type.owner, self.user)
|
|
|
|
self.assertIsNone(self.doc.storage_path)
|
|
self.assertFalse(StoragePath.objects.exists())
|
|
self.assertNotIn("storage_path", changed)
|
|
|
|
def test_overwrite_disabled_keeps_existing_values(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An action without overwrite existing
|
|
- A document that already has a title, created date and
|
|
correspondent
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- The existing values are kept, only the empty document type is
|
|
filled in
|
|
"""
|
|
existing = Correspondent.objects.create(name="Mine", owner=self.user)
|
|
self.doc.correspondent = existing
|
|
self.doc.save()
|
|
action = self.make_action(ai_create_missing=True)
|
|
|
|
changed = self.apply(action)
|
|
|
|
self.assertEqual(self.doc.title, "original.pdf")
|
|
self.assertEqual(self.doc.created, datetime.date(2020, 1, 1))
|
|
self.assertEqual(self.doc.correspondent, existing)
|
|
self.assertEqual(self.doc.document_type.name, "Suggested Document Type")
|
|
self.assertNotIn("title", changed)
|
|
self.assertNotIn("correspondent", changed)
|
|
|
|
def test_overwrite_enabled_replaces_existing_values(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An action with overwrite existing
|
|
- A document that already has a title and created date
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- The suggested values replace them
|
|
"""
|
|
action = self.make_action(
|
|
ai_create_missing=True,
|
|
ai_overwrite_existing=True,
|
|
)
|
|
|
|
changed = self.apply(action)
|
|
|
|
self.assertEqual(self.doc.title, "Suggested Title")
|
|
self.assertEqual(self.doc.created, datetime.date(2024, 3, 5))
|
|
self.assertIn("title", changed)
|
|
self.assertIn("created", changed)
|
|
|
|
def test_tags_are_added_not_replaced(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A document that already has a tag unrelated to the suggestions
|
|
WHEN:
|
|
- The action is applied with overwrite existing enabled
|
|
THEN:
|
|
- The existing tag is kept, since suggested tags are always
|
|
additive regardless of the overwrite setting
|
|
"""
|
|
kept = Tag.objects.create(name="Do Not Remove", owner=self.user)
|
|
self.doc.tags.add(kept)
|
|
Tag.objects.create(name="Existing Tag", owner=self.user)
|
|
action = self.make_action(ai_overwrite_existing=True)
|
|
|
|
self.apply(action)
|
|
|
|
self.assertEqual(
|
|
sorted(t.name for t in self.doc.tags.all()),
|
|
["Do Not Remove", "Existing Tag"],
|
|
)
|
|
|
|
def test_unselected_fields_are_untouched(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- An action that only selects the title
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- Only the title changes, even though the LLM suggested everything
|
|
"""
|
|
action = self.make_action(
|
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.TITLE],
|
|
ai_create_missing=True,
|
|
ai_overwrite_existing=True,
|
|
)
|
|
|
|
changed = self.apply(action)
|
|
|
|
self.assertEqual(changed, ["title"])
|
|
self.assertEqual(self.doc.title, "Suggested Title")
|
|
self.assertEqual(self.doc.tags.count(), 0)
|
|
self.assertIsNone(self.doc.correspondent)
|
|
self.assertEqual(self.doc.created, datetime.date(2020, 1, 1))
|
|
|
|
def test_another_users_private_objects_are_not_matched(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- A suggested tag name that exists, but is owned by someone else
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- It is not assigned, because the document owner cannot see it
|
|
"""
|
|
other = User.objects.create(username="someone-else")
|
|
Tag.objects.create(name="Existing Tag", owner=other)
|
|
action = self.make_action(
|
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.TAGS],
|
|
)
|
|
|
|
self.apply(action)
|
|
|
|
self.assertEqual(self.doc.tags.count(), 0)
|
|
|
|
def test_unparsable_dates_are_skipped(self) -> None:
|
|
"""
|
|
GIVEN:
|
|
- Suggested dates that are not all valid
|
|
WHEN:
|
|
- The action is applied
|
|
THEN:
|
|
- The first usable date is applied and the rest ignored
|
|
"""
|
|
action = self.make_action(
|
|
ai_suggestion_fields=[WorkflowAction.AISuggestionField.CREATED],
|
|
ai_overwrite_existing=True,
|
|
)
|
|
|
|
with mock.patch(
|
|
"documents.workflows.ai.get_ai_document_classification",
|
|
return_value={**SUGGESTIONS, "dates": ["not a date", "2019-07-04"]},
|
|
):
|
|
changed = apply_ai_suggestions_to_document(action, self.doc)
|
|
|
|
self.doc.refresh_from_db()
|
|
self.assertEqual(changed, ["created"])
|
|
self.assertEqual(self.doc.created, datetime.date(2019, 7, 4))
|