mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-13 06:13:20 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fda0460c0 | ||
|
|
b3101d68b1 | ||
|
|
568fc3c9a9 | ||
|
|
898ed02637 | ||
|
|
96f3741475 | ||
|
|
3fab83dff8 | ||
|
|
13376d0c12 |
+15
-1
@@ -650,6 +650,19 @@ happened while it was still encrypted, that original version will likewise be mi
|
|||||||
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
|
**Current limitation**: Passwords are stored as a simple list without descriptions. To handle
|
||||||
multiple PDF types with different passwords, create separate workflows for each use case.
|
multiple PDF types with different passwords, create separate workflows for each use case.
|
||||||
|
|
||||||
|
##### Remote OCR {#workflow-action-remote-ocr}
|
||||||
|
|
||||||
|
"Remote OCR" actions send the document to the configured remote OCR engine instead of processing it
|
||||||
|
locally. To use remote OCR selectively, set the [remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE)
|
||||||
|
to `workflow_only` then add this action to a workflow that matches only the documents you
|
||||||
|
want sent to the remote engine. See [Remote OCR](#remote-ocr) for the engine setup. The action only works with
|
||||||
|
a **Consumption Started** trigger.
|
||||||
|
|
||||||
|
The action takes no options, its presence is what enables remote OCR for a matching document.
|
||||||
|
|
||||||
|
If the remote engine is not configured, or does not support the document's file type, the document is
|
||||||
|
processed locally instead and a warning is written to the log.
|
||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
@@ -1094,7 +1107,8 @@ or page limitations (e.g. with a free tier).
|
|||||||
|
|
||||||
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
|
By default, every document of a supported file type is sent to the remote engine. To use it more selectively, set the
|
||||||
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
|
[remote OCR mode](configuration.md#PAPERLESS_REMOTE_OCR_MODE) to `workflow_only`. Documents are then processed locally
|
||||||
unless a workflow explicitly enables remote OCR for them, so you can limit the remote engine to particular documents.
|
unless a [remote OCR workflow action](#workflow-action-remote-ocr) enables it for them, so you can limit the remote
|
||||||
|
engine to particular documents.
|
||||||
|
|
||||||
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
|
Setting the mode to `workflow_only` also allows the **Reprocess** actions to selectively use remote OCR for individual documents.
|
||||||
|
|
||||||
|
|||||||
+7
@@ -455,6 +455,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@case (WorkflowActionType.RemoteOcr) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<p class="text-muted small" i18n>The document will be sent to the configured remote OCR service. May incur costs.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
+105
-2
@@ -29,6 +29,7 @@ import {
|
|||||||
DocumentSource,
|
DocumentSource,
|
||||||
WorkflowTriggerType,
|
WorkflowTriggerType,
|
||||||
} from 'src/app/data/workflow-trigger'
|
} from 'src/app/data/workflow-trigger'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
@@ -224,7 +225,12 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
).toEqual('Document Added')
|
).toEqual('Document Added')
|
||||||
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
expect(component.getTriggerTypeOptionName(null)).toEqual('')
|
||||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||||
expect(component.actionTypeOptions).toEqual(WORKFLOW_ACTION_OPTIONS)
|
// Remote OCR is absent until the workflow has a consumption trigger
|
||||||
|
expect(component.actionTypeOptions).toEqual(
|
||||||
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
|
(a) => a.id !== WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
component.getActionTypeOptionName(WorkflowActionType.Assignment)
|
||||||
).toEqual('Assignment')
|
).toEqual('Assignment')
|
||||||
@@ -237,7 +243,104 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.actionTypeOptions).toEqual(
|
expect(component.actionTypeOptions).toEqual(
|
||||||
WORKFLOW_ACTION_OPTIONS.filter((a) => a.id !== WorkflowActionType.Email)
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
|
(a) =>
|
||||||
|
a.id !== WorkflowActionType.Email &&
|
||||||
|
a.id !== WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer remote OCR only for consumption workflows', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// A consumption trigger makes the action reachable
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
|
||||||
|
// Any other trigger type runs after the document has been parsed
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 2',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
// Nothing for the action to apply to yet
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
|
||||||
|
// addTrigger creates the form field with emitEvent false, so the options
|
||||||
|
// have to be computed on read rather than cached from valueChanges
|
||||||
|
component.addTrigger()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
|
||||||
|
// Switching that trigger to a type that runs after parsing removes it
|
||||||
|
component.triggerFields
|
||||||
|
.at(0)
|
||||||
|
.get('type')
|
||||||
|
.setValue(WorkflowTriggerType.DocumentAdded)
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep remote OCR listed when an action already uses it', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [{ type: WorkflowActionType.RemoteOcr }],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not offer remote OCR when no engine is configured', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||||
|
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.RemoteOcr
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+40
-10
@@ -148,6 +148,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.MoveToTrash,
|
id: WorkflowActionType.MoveToTrash,
|
||||||
name: $localize`Move to trash`,
|
name: $localize`Move to trash`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowActionType.RemoteOcr,
|
||||||
|
name: $localize`Remote OCR`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
@@ -504,8 +508,6 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
readonly allowedActionTypes = signal([])
|
|
||||||
|
|
||||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||||
FormArray,
|
FormArray,
|
||||||
TriggerFilterOption[]
|
TriggerFilterOption[]
|
||||||
@@ -548,13 +550,40 @@ export class WorkflowEditDialogComponent
|
|||||||
this.checkRemovalActionFields.bind(this)
|
this.checkRemovalActionFields.bind(this)
|
||||||
)
|
)
|
||||||
this.checkRemovalActionFields(this.objectForm.value)
|
this.checkRemovalActionFields(this.objectForm.value)
|
||||||
this.allowedActionTypes.set(
|
}
|
||||||
this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)
|
|
||||||
? WORKFLOW_ACTION_OPTIONS
|
private allowedActionTypes: typeof WORKFLOW_ACTION_OPTIONS = null
|
||||||
: WORKFLOW_ACTION_OPTIONS.filter(
|
|
||||||
(a) => a.id !== WorkflowActionType.Email
|
private getAllowedActionTypes() {
|
||||||
)
|
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||||
)
|
|
||||||
|
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
||||||
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote OCR is decided before the document is parsed, so it is only
|
||||||
|
// offered for workflows that run at consumption.
|
||||||
|
const formWorkflow: Workflow = this.objectForm?.value
|
||||||
|
const remoteOcrUsable =
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
||||||
|
(formWorkflow?.triggers?.some(
|
||||||
|
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||||
|
) ||
|
||||||
|
formWorkflow?.actions?.some(
|
||||||
|
(action) => action.type === WorkflowActionType.RemoteOcr
|
||||||
|
))
|
||||||
|
if (!remoteOcrUsable) {
|
||||||
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.allowedActionTypes?.length === allowed.length &&
|
||||||
|
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
||||||
|
) {
|
||||||
|
return this.allowedActionTypes
|
||||||
|
}
|
||||||
|
this.allowedActionTypes = allowed
|
||||||
|
return allowed
|
||||||
}
|
}
|
||||||
|
|
||||||
private checkRemovalActionFields(formWorkflow: Workflow) {
|
private checkRemovalActionFields(formWorkflow: Workflow) {
|
||||||
@@ -1279,7 +1308,8 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
this.settingsService.trackChanges()
|
this.settingsService.trackChanges()
|
||||||
return this.allowedActionTypes()
|
// Computed on read rather than cached
|
||||||
|
return this.getAllowedActionTypes()
|
||||||
}
|
}
|
||||||
|
|
||||||
getActionTypeOptionName(type: WorkflowActionType): string {
|
getActionTypeOptionName(type: WorkflowActionType): string {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export enum WorkflowActionType {
|
|||||||
Webhook = 4,
|
Webhook = 4,
|
||||||
PasswordRemoval = 5,
|
PasswordRemoval = 5,
|
||||||
MoveToTrash = 6,
|
MoveToTrash = 6,
|
||||||
|
RemoteOcr = 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkflowActionEmail extends ObjectWithId {
|
export interface WorkflowActionEmail extends ObjectWithId {
|
||||||
|
|||||||
@@ -473,6 +473,16 @@ class ConsumerPlugin(
|
|||||||
f"Unsupported mime type {mime_type}",
|
f"Unsupported mime type {mime_type}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if self.metadata.remote_ocr and not getattr(
|
||||||
|
parser_class,
|
||||||
|
"uses_remote_service",
|
||||||
|
False,
|
||||||
|
):
|
||||||
|
self.log.warning(
|
||||||
|
"Remote OCR was requested for this document but no remote "
|
||||||
|
"parser is available for it, processing locally instead.",
|
||||||
|
)
|
||||||
|
|
||||||
# Notify all listeners that we're going to do some work.
|
# Notify all listeners that we're going to do some work.
|
||||||
|
|
||||||
document_consumption_started.send(
|
document_consumption_started.send(
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 5.2.16 on 2026-08-10 17:27
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("documents", "0023_savedview_icon"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="workflowaction",
|
||||||
|
name="type",
|
||||||
|
field=models.PositiveSmallIntegerField(
|
||||||
|
choices=[
|
||||||
|
(1, "Assignment"),
|
||||||
|
(2, "Removal"),
|
||||||
|
(3, "Email"),
|
||||||
|
(4, "Webhook"),
|
||||||
|
(5, "Password removal"),
|
||||||
|
(6, "Move to trash"),
|
||||||
|
(7, "Remote OCR"),
|
||||||
|
],
|
||||||
|
default=1,
|
||||||
|
verbose_name="Workflow Action Type",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1668,6 +1668,10 @@ class WorkflowAction(models.Model):
|
|||||||
6,
|
6,
|
||||||
_("Move to trash"),
|
_("Move to trash"),
|
||||||
)
|
)
|
||||||
|
REMOTE_OCR = (
|
||||||
|
7,
|
||||||
|
_("Remote OCR"),
|
||||||
|
)
|
||||||
|
|
||||||
type = models.PositiveSmallIntegerField(
|
type = models.PositiveSmallIntegerField(
|
||||||
_("Workflow Action Type"),
|
_("Workflow Action Type"),
|
||||||
|
|||||||
@@ -3285,6 +3285,41 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]):
|
|||||||
"actions",
|
"actions",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
attrs = super().validate(attrs)
|
||||||
|
|
||||||
|
if "actions" in attrs:
|
||||||
|
has_remote_ocr_action = any(
|
||||||
|
action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR
|
||||||
|
for action in attrs["actions"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
has_remote_ocr_action = self.instance is not None and (
|
||||||
|
self.instance.actions.filter(
|
||||||
|
type=WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
if "triggers" in attrs:
|
||||||
|
has_consumption_trigger = any(
|
||||||
|
trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION
|
||||||
|
for trigger in attrs["triggers"]
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
has_consumption_trigger = self.instance is not None and (
|
||||||
|
self.instance.triggers.filter(
|
||||||
|
type=WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Remote OCR can only work with consumption triggers
|
||||||
|
if has_remote_ocr_action and not has_consumption_trigger:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"Remote OCR actions require a consumption started trigger",
|
||||||
|
)
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
def update_triggers_and_actions(
|
def update_triggers_and_actions(
|
||||||
self,
|
self,
|
||||||
instance: Workflow,
|
instance: Workflow,
|
||||||
|
|||||||
@@ -971,6 +971,17 @@ def run_workflows(
|
|||||||
)
|
)
|
||||||
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
|
elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH:
|
||||||
has_move_to_trash_action = True
|
has_move_to_trash_action = True
|
||||||
|
elif action.type == WorkflowAction.WorkflowActionType.REMOTE_OCR:
|
||||||
|
if use_overrides and overrides:
|
||||||
|
overrides.remote_ocr = True
|
||||||
|
else:
|
||||||
|
# If a workflow has a consumption trigger *and* another type,
|
||||||
|
# the document has already been parsed by the time the other one fires
|
||||||
|
logger.debug(
|
||||||
|
"Remote OCR action only applies to consumption "
|
||||||
|
"triggers, ignoring",
|
||||||
|
extra={"group": logging_group},
|
||||||
|
)
|
||||||
|
|
||||||
if not use_overrides:
|
if not use_overrides:
|
||||||
# limit title to 128 characters
|
# limit title to 128 characters
|
||||||
|
|||||||
@@ -390,6 +390,141 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
|
|||||||
|
|
||||||
self.assertEqual(Workflow.objects.count(), 1)
|
self.assertEqual(Workflow.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_api_create_remote_ocr_action_requires_consumption_trigger(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create a workflow with a remote OCR action
|
||||||
|
- No consumption started trigger, so the action could never run
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- Correct HTTP 400 response
|
||||||
|
- No objects are created
|
||||||
|
"""
|
||||||
|
existing_count = Workflow.objects.count()
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Remote OCR too late",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(Workflow.objects.count(), existing_count)
|
||||||
|
|
||||||
|
def test_api_create_remote_ocr_action_with_consumption_trigger(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- API request to create a workflow with a remote OCR action
|
||||||
|
- A consumption started trigger alongside another trigger type
|
||||||
|
WHEN:
|
||||||
|
- API is called
|
||||||
|
THEN:
|
||||||
|
- The workflow is created, the action applies to consumption only
|
||||||
|
"""
|
||||||
|
response = self.client.post(
|
||||||
|
self.ENDPOINT,
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"name": "Remote OCR on consume",
|
||||||
|
"order": 1,
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.CONSUMPTION,
|
||||||
|
"filter_filename": "*.pdf",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
def test_api_partial_update_adds_remote_ocr_action(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing workflow with a consumption started trigger
|
||||||
|
WHEN:
|
||||||
|
- A partial update adds a remote OCR action without resubmitting triggers
|
||||||
|
THEN:
|
||||||
|
- The existing trigger is considered and the update succeeds
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{self.workflow.id}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
self.workflow.actions.get().type,
|
||||||
|
WorkflowAction.WorkflowActionType.REMOTE_OCR,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_api_partial_update_cannot_remove_remote_ocr_trigger(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- An existing workflow with a remote OCR action
|
||||||
|
- An existing consumption started trigger
|
||||||
|
WHEN:
|
||||||
|
- A partial update replaces the trigger without resubmitting actions
|
||||||
|
THEN:
|
||||||
|
- The existing action is considered and the update is rejected
|
||||||
|
"""
|
||||||
|
self.action.type = WorkflowAction.WorkflowActionType.REMOTE_OCR
|
||||||
|
self.action.save()
|
||||||
|
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}{self.workflow.id}/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"triggers": [
|
||||||
|
{
|
||||||
|
"type": WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertEqual(self.workflow.triggers.get(), self.trigger)
|
||||||
|
|
||||||
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
def test_api_create_workflow_trigger_action_empty_fields(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -5409,3 +5409,82 @@ class TestDateWorkflowLocalization(
|
|||||||
document = Document.objects.first()
|
document = Document.objects.first()
|
||||||
assert document is not None
|
assert document is not None
|
||||||
assert document.title == expected_title
|
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))
|
||||||
|
|||||||
Reference in New Issue
Block a user