diff --git a/src/documents/signals/handlers.py b/src/documents/signals/handlers.py index 2303999d3..7d55bc801 100644 --- a/src/documents/signals/handlers.py +++ b/src/documents/signals/handlers.py @@ -793,11 +793,7 @@ def run_workflows( logging_group, original_file, ) - elif ( - action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL - and not use_overrides - ): - # Password removal only makes sense on actual documents + elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL: execute_password_removal_action(action, document, logging_group) if not use_overrides: diff --git a/src/documents/tests/test_workflows.py b/src/documents/tests/test_workflows.py index 5b6f338ed..89233373f 100644 --- a/src/documents/tests/test_workflows.py +++ b/src/documents/tests/test_workflows.py @@ -2,6 +2,7 @@ import datetime import json import shutil import socket +import tempfile from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING @@ -60,6 +61,7 @@ from documents.tests.utils import DirectoriesMixin from documents.tests.utils import DummyProgressManager from documents.tests.utils import FileSystemAssertsMixin 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 @@ -3641,6 +3643,68 @@ class TestWorkflows( mock_remove_password.assert_not_called() + @mock.patch("documents.bulk_edit.remove_password") + def test_password_removal_consumable_document_deferred( + self, + mock_remove_password, + ): + 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, + ) + + 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, + ), + mock.call( + [doc.id], + password="second", + update_document=True, + user=doc.owner, + ), + ], + ) + + # ensure handler disconnected after first run + document_consumption_finished.send( + sender=self.__class__, + document=doc, + ) + assert mock_remove_password.call_count == 2 + class TestWebhookSend: def test_send_webhook_data_or_json( diff --git a/src/documents/workflows/actions.py b/src/documents/workflows/actions.py index c18a5a6a2..6c3c49ba8 100644 --- a/src/documents/workflows/actions.py +++ b/src/documents/workflows/actions.py @@ -15,6 +15,7 @@ from documents.models import Document from documents.models import DocumentType from documents.models import WorkflowAction from documents.models import WorkflowTrigger +from documents.signals import document_consumption_finished from documents.templating.workflows import parse_w_workflow_placeholders from documents.workflows.webhooks import send_webhook @@ -264,7 +265,7 @@ def execute_webhook_action( def execute_password_removal_action( action: WorkflowAction, - document: Document, + document: Document | ConsumableDocument, logging_group, ) -> None: """ @@ -285,6 +286,21 @@ def execute_password_removal_action( if password.strip() ] + if isinstance(document, ConsumableDocument): + # hook the consumption-finished signal to attempt password removal later + def handler(sender, **kwargs): + consumed_document: Document = kwargs.get("document") + if consumed_document is not None: + execute_password_removal_action( + action, + consumed_document, + logging_group, + ) + document_consumption_finished.disconnect(handler) + + document_consumption_finished.connect(handler, weak=False) + return + # import here to avoid circular dependency from documents.bulk_edit import remove_password