From 67628401c8d5ff89a425f0e57ac5cf6b126a48ea Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:43:26 -0700 Subject: [PATCH] Ok! Backend stuff for the remote ocr workflow --- src/documents/consumer.py | 10 +++ .../0023_alter_workflowaction_type.py | 30 +++++++ src/documents/models.py | 4 + src/documents/serialisers.py | 20 +++++ src/documents/signals/handlers.py | 11 +++ src/documents/tests/test_api_workflows.py | 76 ++++++++++++++++++ src/documents/tests/test_workflows.py | 79 +++++++++++++++++++ 7 files changed, 230 insertions(+) create mode 100644 src/documents/migrations/0023_alter_workflowaction_type.py diff --git a/src/documents/consumer.py b/src/documents/consumer.py index 683f8f11a..79d0cfa8b 100644 --- a/src/documents/consumer.py +++ b/src/documents/consumer.py @@ -473,6 +473,16 @@ class ConsumerPlugin( 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. document_consumption_started.send( diff --git a/src/documents/migrations/0023_alter_workflowaction_type.py b/src/documents/migrations/0023_alter_workflowaction_type.py new file mode 100644 index 000000000..b9d00acbd --- /dev/null +++ b/src/documents/migrations/0023_alter_workflowaction_type.py @@ -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", "0022_add_perf_indexes"), + ] + + 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", + ), + ), + ] diff --git a/src/documents/models.py b/src/documents/models.py index c9b7109bc..687eafa49 100644 --- a/src/documents/models.py +++ b/src/documents/models.py @@ -1599,6 +1599,10 @@ class WorkflowAction(models.Model): 6, _("Move to trash"), ) + REMOTE_OCR = ( + 7, + _("Remote OCR"), + ) type = models.PositiveSmallIntegerField( _("Workflow Action Type"), diff --git a/src/documents/serialisers.py b/src/documents/serialisers.py index 41b8d824d..d43c0cd12 100644 --- a/src/documents/serialisers.py +++ b/src/documents/serialisers.py @@ -3277,6 +3277,26 @@ class WorkflowSerializer(serializers.ModelSerializer[Workflow]): "actions", ] + def validate(self, attrs): + attrs = super().validate(attrs) + + triggers = attrs.get("triggers") or [] + actions = attrs.get("actions") or [] + + # Remote OCR can only work with consumption triggers + if any( + action.get("type") == WorkflowAction.WorkflowActionType.REMOTE_OCR + for action in actions + ) and not any( + trigger.get("type") == WorkflowTrigger.WorkflowTriggerType.CONSUMPTION + for trigger in triggers + ): + raise serializers.ValidationError( + "Remote OCR actions require a consumption started trigger", + ) + + return attrs + def update_triggers_and_actions( self, instance: Workflow, diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 30c682cbf..1221ec037 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -971,6 +971,17 @@ def run_workflows( ) elif action.type == WorkflowAction.WorkflowActionType.MOVE_TO_TRASH: 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: # limit title to 128 characters diff --git a/src/documents/tests/test_api_workflows.py b/src/documents/tests/test_api_workflows.py index cdd3a5f40..58c9379d7 100644 --- a/src/documents/tests/test_api_workflows.py +++ b/src/documents/tests/test_api_workflows.py @@ -390,6 +390,82 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase): 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_create_workflow_trigger_action_empty_fields(self) -> None: """ GIVEN: diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index f3dace48f..9e91151c0 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -5360,3 +5360,82 @@ class TestDateWorkflowLocalization( 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))