mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-03-13 04:31:23 +00:00
Compare commits
4 Commits
fix-drop-s
...
feature-ve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85a360c21 | ||
|
|
add2c68b7c | ||
|
|
3f0770a9a6 | ||
|
|
c0c09bd0da |
@@ -458,7 +458,7 @@ fields and permissions, which will be merged.
|
|||||||
|
|
||||||
#### Types {#workflow-trigger-types}
|
#### Types {#workflow-trigger-types}
|
||||||
|
|
||||||
Currently, there are four events that correspond to workflow trigger 'types':
|
Currently, there are five events that correspond to workflow trigger 'types':
|
||||||
|
|
||||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||||
folder or API), file path, file name, mail rule
|
folder or API), file path, file name, mail rule
|
||||||
@@ -470,8 +470,10 @@ Currently, there are four events that correspond to workflow trigger 'types':
|
|||||||
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
||||||
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
||||||
offsets will trigger after the date, negative offsets will trigger before).
|
offsets will trigger after the date, negative offsets will trigger before).
|
||||||
|
5. **Version Added**: when a new version is added for an existing document. This trigger evaluates filters against the root document
|
||||||
|
and applies actions to the root document.
|
||||||
|
|
||||||
The following flow diagram illustrates the four document trigger types:
|
The following flow diagram illustrates the document trigger types:
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
@@ -487,6 +489,10 @@ flowchart TD
|
|||||||
'Updated'
|
'Updated'
|
||||||
trigger(s)"}
|
trigger(s)"}
|
||||||
|
|
||||||
|
version{"Matching
|
||||||
|
'Version Added'
|
||||||
|
trigger(s)"}
|
||||||
|
|
||||||
scheduled{"Documents
|
scheduled{"Documents
|
||||||
matching
|
matching
|
||||||
trigger(s)"}
|
trigger(s)"}
|
||||||
@@ -503,11 +509,15 @@ flowchart TD
|
|||||||
updated --> |Yes| J[Workflow Actions Run]
|
updated --> |Yes| J[Workflow Actions Run]
|
||||||
updated --> |No| K
|
updated --> |No| K
|
||||||
J --> K[Document Saved]
|
J --> K[Document Saved]
|
||||||
L[Scheduled Task Check<br/>hourly at :05] --> M[Get All Scheduled Triggers]
|
L[New Document Version Added] --> version
|
||||||
M --> scheduled
|
version --> |Yes| V[Workflow Actions Run]
|
||||||
scheduled --> |Yes| N[Workflow Actions Run]
|
version --> |No| W
|
||||||
scheduled --> |No| O[Document Saved]
|
V --> W[Document Saved]
|
||||||
N --> O
|
X[Scheduled Task Check<br/>hourly at :05] --> Y[Get All Scheduled Triggers]
|
||||||
|
Y --> scheduled
|
||||||
|
scheduled --> |Yes| Z[Workflow Actions Run]
|
||||||
|
scheduled --> |No| AA[Document Saved]
|
||||||
|
Z --> AA
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Filters {#workflow-trigger-filters}
|
#### Filters {#workflow-trigger-filters}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@
|
|||||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
|
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized." [error]="error?.filter_path"></pngx-input-text>
|
||||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||||
}
|
}
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.VersionAdded) {
|
||||||
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (matchingPatternRequired(formGroup)) {
|
@if (matchingPatternRequired(formGroup)) {
|
||||||
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled || formGroup.get('type').value === WorkflowTriggerType.VersionAdded) {
|
||||||
<div class="row mt-3">
|
<div class="row mt-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="trigger-filters mb-3">
|
<div class="trigger-filters mb-3">
|
||||||
|
|||||||
@@ -120,6 +120,10 @@ export const WORKFLOW_TYPE_OPTIONS = [
|
|||||||
id: WorkflowTriggerType.Scheduled,
|
id: WorkflowTriggerType.Scheduled,
|
||||||
name: $localize`Scheduled`,
|
name: $localize`Scheduled`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowTriggerType.VersionAdded,
|
||||||
|
name: $localize`Version Added`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export const WORKFLOW_ACTION_OPTIONS = [
|
export const WORKFLOW_ACTION_OPTIONS = [
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum WorkflowTriggerType {
|
|||||||
DocumentAdded = 2,
|
DocumentAdded = 2,
|
||||||
DocumentUpdated = 3,
|
DocumentUpdated = 3,
|
||||||
Scheduled = 4,
|
Scheduled = 4,
|
||||||
|
VersionAdded = 5,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ScheduleDateField {
|
export enum ScheduleDateField {
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ class DocumentsConfig(AppConfig):
|
|||||||
def ready(self) -> None:
|
def ready(self) -> None:
|
||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
|
from documents.signals import document_version_added
|
||||||
from documents.signals.handlers import add_inbox_tags
|
from documents.signals.handlers import add_inbox_tags
|
||||||
from documents.signals.handlers import add_or_update_document_in_llm_index
|
from documents.signals.handlers import add_or_update_document_in_llm_index
|
||||||
from documents.signals.handlers import add_to_index
|
from documents.signals.handlers import add_to_index
|
||||||
from documents.signals.handlers import run_workflows_added
|
from documents.signals.handlers import run_workflows_added
|
||||||
from documents.signals.handlers import run_workflows_updated
|
from documents.signals.handlers import run_workflows_updated
|
||||||
|
from documents.signals.handlers import run_workflows_version_added
|
||||||
from documents.signals.handlers import send_websocket_document_updated
|
from documents.signals.handlers import send_websocket_document_updated
|
||||||
from documents.signals.handlers import set_correspondent
|
from documents.signals.handlers import set_correspondent
|
||||||
from documents.signals.handlers import set_document_type
|
from documents.signals.handlers import set_document_type
|
||||||
@@ -28,6 +30,7 @@ class DocumentsConfig(AppConfig):
|
|||||||
document_consumption_finished.connect(set_storage_path)
|
document_consumption_finished.connect(set_storage_path)
|
||||||
document_consumption_finished.connect(add_to_index)
|
document_consumption_finished.connect(add_to_index)
|
||||||
document_consumption_finished.connect(run_workflows_added)
|
document_consumption_finished.connect(run_workflows_added)
|
||||||
|
document_version_added.connect(run_workflows_version_added)
|
||||||
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
document_consumption_finished.connect(add_or_update_document_in_llm_index)
|
||||||
document_updated.connect(run_workflows_updated)
|
document_updated.connect(run_workflows_updated)
|
||||||
document_updated.connect(send_websocket_document_updated)
|
document_updated.connect(send_websocket_document_updated)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ from documents.plugins.helpers import ProgressStatusOptions
|
|||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
from documents.signals import document_consumption_started
|
from documents.signals import document_consumption_started
|
||||||
from documents.signals import document_updated
|
from documents.signals import document_updated
|
||||||
|
from documents.signals import document_version_added
|
||||||
from documents.signals.handlers import run_workflows
|
from documents.signals.handlers import run_workflows
|
||||||
from documents.templating.workflows import parse_w_workflow_placeholders
|
from documents.templating.workflows import parse_w_workflow_placeholders
|
||||||
from documents.utils import copy_basic_file_stats
|
from documents.utils import copy_basic_file_stats
|
||||||
@@ -601,6 +602,12 @@ class ConsumerPlugin(
|
|||||||
if self.unmodified_original
|
if self.unmodified_original
|
||||||
else self.working_copy,
|
else self.working_copy,
|
||||||
)
|
)
|
||||||
|
if document.root_document_id:
|
||||||
|
document_version_added.send(
|
||||||
|
sender=self.__class__,
|
||||||
|
document=document,
|
||||||
|
logging_group=self.logging_group,
|
||||||
|
)
|
||||||
|
|
||||||
# After everything is in the database, copy the files into
|
# After everything is in the database, copy the files into
|
||||||
# place. If this fails, we'll also rollback the transaction.
|
# place. If this fails, we'll also rollback the transaction.
|
||||||
|
|||||||
@@ -689,6 +689,7 @@ def document_matches_workflow(
|
|||||||
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
|
trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED
|
||||||
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
|
or trigger_type == WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED
|
||||||
or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED
|
or trigger_type == WorkflowTrigger.WorkflowTriggerType.SCHEDULED
|
||||||
|
or trigger_type == WorkflowTrigger.WorkflowTriggerType.VERSION_ADDED
|
||||||
):
|
):
|
||||||
trigger_matched, reason = existing_document_matches_workflow(
|
trigger_matched, reason = existing_document_matches_workflow(
|
||||||
document,
|
document,
|
||||||
|
|||||||
28
src/documents/migrations/0017_alter_workflowtrigger_type.py
Normal file
28
src/documents/migrations/0017_alter_workflowtrigger_type.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2026-03-02 00:00
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0016_document_version_index_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowtrigger",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Consumption Started"),
|
||||||
|
(2, "Document Added"),
|
||||||
|
(3, "Document Updated"),
|
||||||
|
(4, "Scheduled"),
|
||||||
|
(5, "Version Added"),
|
||||||
|
],
|
||||||
|
default=1,
|
||||||
|
verbose_name="Workflow Trigger Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1132,6 +1132,7 @@ class WorkflowTrigger(models.Model):
|
|||||||
DOCUMENT_ADDED = 2, _("Document Added")
|
DOCUMENT_ADDED = 2, _("Document Added")
|
||||||
DOCUMENT_UPDATED = 3, _("Document Updated")
|
DOCUMENT_UPDATED = 3, _("Document Updated")
|
||||||
SCHEDULED = 4, _("Scheduled")
|
SCHEDULED = 4, _("Scheduled")
|
||||||
|
VERSION_ADDED = 5, _("Version Added")
|
||||||
|
|
||||||
class DocumentSourceChoices(models.IntegerChoices):
|
class DocumentSourceChoices(models.IntegerChoices):
|
||||||
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
CONSUME_FOLDER = DocumentSource.ConsumeFolder.value, _("Consume Folder")
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ from django.dispatch import Signal
|
|||||||
|
|
||||||
document_consumption_started = Signal()
|
document_consumption_started = Signal()
|
||||||
document_consumption_finished = Signal()
|
document_consumption_finished = Signal()
|
||||||
|
document_version_added = Signal()
|
||||||
document_consumer_declaration = Signal()
|
document_consumer_declaration = Signal()
|
||||||
document_updated = Signal()
|
document_updated = Signal()
|
||||||
|
|||||||
@@ -783,6 +783,19 @@ def run_workflows_added(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_workflows_version_added(
|
||||||
|
sender,
|
||||||
|
document: Document,
|
||||||
|
logging_group: uuid.UUID | None = None,
|
||||||
|
**kwargs,
|
||||||
|
) -> None:
|
||||||
|
run_workflows(
|
||||||
|
trigger_type=WorkflowTrigger.WorkflowTriggerType.VERSION_ADDED,
|
||||||
|
document=document.root_document,
|
||||||
|
logging_group=logging_group,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_workflows_updated(
|
def run_workflows_updated(
|
||||||
sender,
|
sender,
|
||||||
document: Document,
|
document: Document,
|
||||||
|
|||||||
@@ -715,9 +715,16 @@ class TestConsumer(
|
|||||||
self._assert_first_last_send_progress()
|
self._assert_first_last_send_progress()
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
|
@mock.patch("documents.consumer.document_updated.send")
|
||||||
|
@mock.patch("documents.consumer.document_version_added.send")
|
||||||
@mock.patch("documents.consumer.load_classifier")
|
@mock.patch("documents.consumer.load_classifier")
|
||||||
def test_consume_version_creates_new_version(self, m) -> None:
|
def test_consume_version_creates_new_version(
|
||||||
m.return_value = MagicMock()
|
self,
|
||||||
|
mock_load_classifier: mock.Mock,
|
||||||
|
mock_document_version_added_send: mock.Mock,
|
||||||
|
mock_document_updated_send: mock.Mock,
|
||||||
|
) -> None:
|
||||||
|
mock_load_classifier.return_value = MagicMock()
|
||||||
|
|
||||||
with self.get_consumer(self.get_test_file()) as consumer:
|
with self.get_consumer(self.get_test_file()) as consumer:
|
||||||
consumer.run()
|
consumer.run()
|
||||||
@@ -785,6 +792,16 @@ class TestConsumer(
|
|||||||
self.assertIsNone(version.archive_serial_number)
|
self.assertIsNone(version.archive_serial_number)
|
||||||
self.assertEqual(version.original_filename, version_file.name)
|
self.assertEqual(version.original_filename, version_file.name)
|
||||||
self.assertTrue(bool(version.content))
|
self.assertTrue(bool(version.content))
|
||||||
|
mock_document_version_added_send.assert_called_once()
|
||||||
|
self.assertEqual(
|
||||||
|
mock_document_version_added_send.call_args.kwargs["document"].id,
|
||||||
|
version.id,
|
||||||
|
)
|
||||||
|
mock_document_updated_send.assert_called_once()
|
||||||
|
self.assertEqual(
|
||||||
|
mock_document_updated_send.call_args.kwargs["document"].id,
|
||||||
|
root_doc.id,
|
||||||
|
)
|
||||||
|
|
||||||
@override_settings(AUDIT_LOG_ENABLED=True)
|
@override_settings(AUDIT_LOG_ENABLED=True)
|
||||||
@mock.patch("documents.consumer.load_classifier")
|
@mock.patch("documents.consumer.load_classifier")
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ from documents.models import WorkflowTrigger
|
|||||||
from documents.plugins.base import StopConsumeTaskError
|
from documents.plugins.base import StopConsumeTaskError
|
||||||
from documents.serialisers import WorkflowTriggerSerializer
|
from documents.serialisers import WorkflowTriggerSerializer
|
||||||
from documents.signals import document_consumption_finished
|
from documents.signals import document_consumption_finished
|
||||||
|
from documents.signals import document_version_added
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
from documents.tests.utils import DummyProgressManager
|
from documents.tests.utils import DummyProgressManager
|
||||||
from documents.tests.utils import FileSystemAssertsMixin
|
from documents.tests.utils import FileSystemAssertsMixin
|
||||||
@@ -1786,6 +1787,53 @@ class TestWorkflows(
|
|||||||
).exists(),
|
).exists(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_version_added_workflow_runs_on_root_document(self) -> None:
|
||||||
|
trigger = WorkflowTrigger.objects.create(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.VERSION_ADDED,
|
||||||
|
)
|
||||||
|
action = WorkflowAction.objects.create(
|
||||||
|
assign_title="Updated by version",
|
||||||
|
assign_owner=self.user2,
|
||||||
|
)
|
||||||
|
workflow = Workflow.objects.create(
|
||||||
|
name="Version workflow",
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
document_version_added.send(
|
||||||
|
sender=self.__class__,
|
||||||
|
document=version_doc,
|
||||||
|
)
|
||||||
|
|
||||||
|
root_doc.refresh_from_db()
|
||||||
|
version_doc.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(root_doc.title, "Updated by version")
|
||||||
|
self.assertEqual(root_doc.owner, self.user2)
|
||||||
|
self.assertIsNone(version_doc.owner)
|
||||||
|
self.assertEqual(
|
||||||
|
WorkflowRun.objects.filter(
|
||||||
|
workflow=workflow,
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.VERSION_ADDED,
|
||||||
|
document=root_doc,
|
||||||
|
).count(),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
|
||||||
def test_document_updated_workflow(self) -> None:
|
def test_document_updated_workflow(self) -> None:
|
||||||
trigger = WorkflowTrigger.objects.create(
|
trigger = WorkflowTrigger.objects.create(
|
||||||
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
type=WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
|||||||
Reference in New Issue
Block a user