Merge branch 'dev' into feature-date-parse-plugin

This commit is contained in:
Trenton H
2026-02-03 10:30:35 -08:00
committed by GitHub
14 changed files with 572 additions and 65 deletions

View File

@@ -562,8 +562,8 @@ you may want to adjust these settings to prevent abuse.
#### Workflow placeholders #### Workflow placeholders
Titles can be assigned 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/).
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures) This allows for complex logic to be used, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11). and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
The template is provided as a string. The template is provided as a string.
@@ -586,7 +586,7 @@ applied. You can use the following placeholders in the template with any trigger
- `{{added_time}}`: added time in HH:MM format - `{{added_time}}`: added time in HH:MM format
- `{{original_filename}}`: original file name without extension - `{{original_filename}}`: original file name without extension
- `{{filename}}`: current file name without extension - `{{filename}}`: current file name without extension
- `{{doc_title}}`: current document title - `{{doc_title}}`: current document title (cannot be used in title assignment)
The following placeholders are only available for "added" or "updated" triggers The following placeholders are only available for "added" or "updated" triggers

View File

@@ -5359,6 +5359,27 @@
<context context-type="linenumber">429</context> <context context-type="linenumber">429</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="32686762098259088" datatype="html">
<source> One password per line. The workflow will try them in order until one succeeds. </source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">436,438</context>
</context-group>
</trans-unit>
<trans-unit id="3853121441237751087" datatype="html">
<source>Passwords</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">441</context>
</context-group>
</trans-unit>
<trans-unit id="3653669613103848563" datatype="html">
<source>Passwords are stored in plain text. Use with caution.</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.html</context>
<context context-type="linenumber">445</context>
</context-group>
</trans-unit>
<trans-unit id="4626030417479279989" datatype="html"> <trans-unit id="4626030417479279989" datatype="html">
<source>Consume Folder</source> <source>Consume Folder</source>
<context-group purpose="location"> <context-group purpose="location">
@@ -5454,109 +5475,116 @@
<context context-type="linenumber">140</context> <context context-type="linenumber">140</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4824906895380506720" datatype="html">
<source>Password removal</source>
<context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">144</context>
</context-group>
</trans-unit>
<trans-unit id="4522609911791833187" datatype="html"> <trans-unit id="4522609911791833187" datatype="html">
<source>Has any of these tags</source> <source>Has any of these tags</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">209</context> <context context-type="linenumber">213</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4166903555074156852" datatype="html"> <trans-unit id="4166903555074156852" datatype="html">
<source>Has all of these tags</source> <source>Has all of these tags</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">216</context> <context context-type="linenumber">220</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6624363795312783141" datatype="html"> <trans-unit id="6624363795312783141" datatype="html">
<source>Does not have these tags</source> <source>Does not have these tags</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">223</context> <context context-type="linenumber">227</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7168528512669831184" datatype="html"> <trans-unit id="7168528512669831184" datatype="html">
<source>Has any of these correspondents</source> <source>Has any of these correspondents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">230</context> <context context-type="linenumber">234</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5281365940563983618" datatype="html"> <trans-unit id="5281365940563983618" datatype="html">
<source>Has correspondent</source> <source>Has correspondent</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">238</context> <context context-type="linenumber">242</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6884498632428600393" datatype="html"> <trans-unit id="6884498632428600393" datatype="html">
<source>Does not have correspondents</source> <source>Does not have correspondents</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">246</context> <context context-type="linenumber">250</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4806713133917046341" datatype="html"> <trans-unit id="4806713133917046341" datatype="html">
<source>Has document type</source> <source>Has document type</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">254</context> <context context-type="linenumber">258</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8801397520369995032" datatype="html"> <trans-unit id="8801397520369995032" datatype="html">
<source>Has any of these document types</source> <source>Has any of these document types</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">262</context> <context context-type="linenumber">266</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1507843981661822403" datatype="html"> <trans-unit id="1507843981661822403" datatype="html">
<source>Does not have document types</source> <source>Does not have document types</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">270</context> <context context-type="linenumber">274</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4277260190522078330" datatype="html"> <trans-unit id="4277260190522078330" datatype="html">
<source>Has storage path</source> <source>Has storage path</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">278</context> <context context-type="linenumber">282</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8858580062214623097" datatype="html"> <trans-unit id="8858580062214623097" datatype="html">
<source>Has any of these storage paths</source> <source>Has any of these storage paths</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">286</context> <context context-type="linenumber">290</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6070943364927280151" datatype="html"> <trans-unit id="6070943364927280151" datatype="html">
<source>Does not have storage paths</source> <source>Does not have storage paths</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">294</context> <context context-type="linenumber">298</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6250799006816371860" datatype="html"> <trans-unit id="6250799006816371860" datatype="html">
<source>Matches custom field query</source> <source>Matches custom field query</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">302</context> <context context-type="linenumber">306</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3138206142174978019" datatype="html"> <trans-unit id="3138206142174978019" datatype="html">
<source>Create new workflow</source> <source>Create new workflow</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">531</context> <context context-type="linenumber">535</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5996779210524133604" datatype="html"> <trans-unit id="5996779210524133604" datatype="html">
<source>Edit workflow</source> <source>Edit workflow</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context> <context context-type="sourcefile">src/app/components/common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component.ts</context>
<context context-type="linenumber">535</context> <context context-type="linenumber">539</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5457837313196342910" datatype="html"> <trans-unit id="5457837313196342910" datatype="html">

View File

@@ -430,6 +430,24 @@
</div> </div>
</div> </div>
} }
@case (WorkflowActionType.PasswordRemoval) {
<div class="row">
<div class="col">
<p class="small" i18n>
One password per line. The workflow will try them in order until one succeeds.
</p>
<pngx-input-textarea
i18n-title
title="Passwords"
formControlName="passwords"
rows="4"
[error]="error?.actions?.[i]?.passwords"
hint="Passwords are stored in plain text. Use with caution."
i18n-hint
></pngx-input-textarea>
</div>
</div>
}
} }
</div> </div>
</ng-template> </ng-template>

View File

@@ -3,6 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { import {
FormArray,
FormControl, FormControl,
FormGroup, FormGroup,
FormsModule, FormsModule,
@@ -994,4 +995,32 @@ describe('WorkflowEditDialogComponent', () => {
component.removeSelectedCustomField(3, formGroup) component.removeSelectedCustomField(3, formGroup)
expect(formGroup.get('assign_custom_fields').value).toEqual([]) expect(formGroup.get('assign_custom_fields').value).toEqual([])
}) })
it('should handle parsing of passwords from array to string and back on save', () => {
const passwordAction: WorkflowAction = {
id: 1,
type: WorkflowActionType.PasswordRemoval,
passwords: ['pass1', 'pass2'],
}
component.object = {
name: 'Workflow with Passwords',
id: 1,
order: 1,
enabled: true,
triggers: [],
actions: [passwordAction],
}
component.ngOnInit()
const formActions = component.objectForm.get('actions') as FormArray
expect(formActions.value[0].passwords).toBe('pass1\npass2')
formActions.at(0).get('passwords').setValue('pass1\npass2\npass3')
component.save()
expect(component.objectForm.get('actions').value[0].passwords).toEqual([
'pass1',
'pass2',
'pass3',
])
})
}) })

View File

@@ -139,6 +139,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
id: WorkflowActionType.Webhook, id: WorkflowActionType.Webhook,
name: $localize`Webhook`, name: $localize`Webhook`,
}, },
{
id: WorkflowActionType.PasswordRemoval,
name: $localize`Password removal`,
},
] ]
export enum TriggerFilterType { export enum TriggerFilterType {
@@ -1202,11 +1206,25 @@ export class WorkflowEditDialogComponent
headers: new FormControl(action.webhook?.headers), headers: new FormControl(action.webhook?.headers),
include_document: new FormControl(!!action.webhook?.include_document), include_document: new FormControl(!!action.webhook?.include_document),
}), }),
passwords: new FormControl(
this.formatPasswords(action.passwords ?? [])
),
}), }),
{ emitEvent } { emitEvent }
) )
} }
private formatPasswords(passwords: string[] = []): string {
return passwords.join('\n')
}
private parsePasswords(value: string = ''): string[] {
return value
.split(/[\n,]+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
private updateAllTriggerActionFields(emitEvent: boolean = false) { private updateAllTriggerActionFields(emitEvent: boolean = false) {
this.triggerFields.clear({ emitEvent: false }) this.triggerFields.clear({ emitEvent: false })
this.object?.triggers.forEach((trigger) => { this.object?.triggers.forEach((trigger) => {
@@ -1331,6 +1349,7 @@ export class WorkflowEditDialogComponent
headers: null, headers: null,
include_document: false, include_document: false,
}, },
passwords: [],
} }
this.object.actions.push(action) this.object.actions.push(action)
this.createActionField(action) this.createActionField(action)
@@ -1367,6 +1386,7 @@ export class WorkflowEditDialogComponent
if (action.type !== WorkflowActionType.Email) { if (action.type !== WorkflowActionType.Email) {
action.email = null action.email = null
} }
action.passwords = this.parsePasswords(action.passwords as any)
}) })
super.save() super.save()
} }

View File

@@ -5,6 +5,7 @@ export enum WorkflowActionType {
Removal = 2, Removal = 2,
Email = 3, Email = 3,
Webhook = 4, Webhook = 4,
PasswordRemoval = 5,
} }
export interface WorkflowActionEmail extends ObjectWithId { export interface WorkflowActionEmail extends ObjectWithId {
@@ -97,4 +98,6 @@ export interface WorkflowAction extends ObjectWithId {
email?: WorkflowActionEmail email?: WorkflowActionEmail
webhook?: WorkflowActionWebhook webhook?: WorkflowActionWebhook
passwords?: string[]
} }

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.7 on 2025-12-29 03:56
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0008_sharelinkbundle"),
]
operations = [
migrations.AddField(
model_name="workflowaction",
name="passwords",
field=models.JSONField(
blank=True,
help_text="Passwords to try when removing PDF protection. Separate with commas or new lines.",
null=True,
verbose_name="passwords",
),
),
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
]

View File

@@ -1405,6 +1405,10 @@ class WorkflowAction(models.Model):
4, 4,
_("Webhook"), _("Webhook"),
) )
PASSWORD_REMOVAL = (
5,
_("Password removal"),
)
type = models.PositiveIntegerField( type = models.PositiveIntegerField(
_("Workflow Action Type"), _("Workflow Action Type"),
@@ -1634,6 +1638,15 @@ class WorkflowAction(models.Model):
verbose_name=_("webhook"), verbose_name=_("webhook"),
) )
passwords = models.JSONField(
_("passwords"),
null=True,
blank=True,
help_text=_(
"Passwords to try when removing PDF protection. Separate with commas or new lines.",
),
)
class Meta: class Meta:
verbose_name = _("workflow action") verbose_name = _("workflow action")
verbose_name_plural = _("workflow actions") verbose_name_plural = _("workflow actions")

View File

@@ -2627,6 +2627,7 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"remove_change_groups", "remove_change_groups",
"email", "email",
"webhook", "webhook",
"passwords",
] ]
def validate(self, attrs): def validate(self, attrs):
@@ -2683,6 +2684,23 @@ class WorkflowActionSerializer(serializers.ModelSerializer):
"Webhook data is required for webhook actions", "Webhook data is required for webhook actions",
) )
if (
"type" in attrs
and attrs["type"] == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL
):
passwords = attrs.get("passwords")
# ensure passwords is a non-empty list of non-empty strings
if (
passwords is None
or not isinstance(passwords, list)
or len(passwords) == 0
or any(not isinstance(pw, str) for pw in passwords)
or any(len(pw.strip()) == 0 for pw in passwords)
):
raise serializers.ValidationError(
"Passwords are required for password removal actions",
)
return attrs return attrs

View File

@@ -48,6 +48,7 @@ from documents.permissions import get_objects_for_user_owner_aware
from documents.templating.utils import convert_format_str_to_template_format from documents.templating.utils import convert_format_str_to_template_format
from documents.workflows.actions import build_workflow_action_context from documents.workflows.actions import build_workflow_action_context
from documents.workflows.actions import execute_email_action from documents.workflows.actions import execute_email_action
from documents.workflows.actions import execute_password_removal_action
from documents.workflows.actions import execute_webhook_action from documents.workflows.actions import execute_webhook_action
from documents.workflows.mutations import apply_assignment_to_document from documents.workflows.mutations import apply_assignment_to_document
from documents.workflows.mutations import apply_assignment_to_overrides from documents.workflows.mutations import apply_assignment_to_overrides
@@ -831,6 +832,8 @@ def run_workflows(
logging_group, logging_group,
original_file, original_file,
) )
elif action.type == WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL:
execute_password_removal_action(action, document, logging_group)
if not use_overrides: if not use_overrides:
# limit title to 128 characters # limit title to 128 characters

View File

@@ -838,3 +838,61 @@ class TestApiWorkflows(DirectoriesMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.action.refresh_from_db() self.action.refresh_from_db()
self.assertEqual(self.action.assign_title, "Patched Title") self.assertEqual(self.action.assign_title, "Patched Title")
def test_password_action_passwords_field(self):
"""
GIVEN:
- Nothing
WHEN:
- A workflow password removal action is created with passwords set
THEN:
- The passwords field is correctly stored and retrieved
"""
passwords = ["password1", "password2", "password3"]
response = self.client.post(
"/api/workflow_actions/",
json.dumps(
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": passwords,
},
),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(response.data["passwords"], passwords)
def test_password_action_invalid_passwords_field(self):
"""
GIVEN:
- Nothing
WHEN:
- A workflow password removal action is created with invalid passwords field
THEN:
- The required validation error is raised
"""
for payload in [
{"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL},
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": "",
},
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": [],
},
{
"type": WorkflowAction.WorkflowActionType.PASSWORD_REMOVAL,
"passwords": ["", "password2"],
},
]:
response = self.client.post(
"/api/workflow_actions/",
json.dumps(payload),
content_type="application/json",
)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn(
"Passwords are required",
str(response.data["non_field_errors"][0]),
)

View File

@@ -2,6 +2,7 @@ import datetime
import json import json
import shutil import shutil
import socket import socket
import tempfile
from datetime import timedelta from datetime import timedelta
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING 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 DummyProgressManager
from documents.tests.utils import FileSystemAssertsMixin from documents.tests.utils import FileSystemAssertsMixin
from documents.tests.utils import SampleDirMixin 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 MailAccount
from paperless_mail.models import MailRule from paperless_mail.models import MailRule
@@ -3722,6 +3724,196 @@ class TestWorkflows(
mock_post.assert_called_once() mock_post.assert_called_once()
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_attempts_multiple_passwords(
self,
mock_remove_password,
):
"""
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\n 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,
),
mock.call(
[doc.id],
password="right",
update_document=True,
user=doc.owner,
),
],
)
@mock.patch("documents.bulk_edit.remove_password")
def test_password_removal_action_fails_without_correct_password(
self,
mock_remove_password,
):
"""
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=" \n , ",
)
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,
):
"""
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,
):
"""
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,
)
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: class TestWebhookSend:
def test_send_webhook_data_or_json( def test_send_webhook_data_or_json(

View File

@@ -1,4 +1,5 @@
import logging import logging
import re
from pathlib import Path from pathlib import Path
from django.conf import settings from django.conf import settings
@@ -14,6 +15,7 @@ from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import WorkflowAction from documents.models import WorkflowAction
from documents.models import WorkflowTrigger from documents.models import WorkflowTrigger
from documents.signals import document_consumption_finished
from documents.templating.workflows import parse_w_workflow_placeholders from documents.templating.workflows import parse_w_workflow_placeholders
from documents.workflows.webhooks import send_webhook from documents.workflows.webhooks import send_webhook
@@ -265,3 +267,74 @@ def execute_webhook_action(
f"Error occurred sending webhook: {e}", f"Error occurred sending webhook: {e}",
extra={"group": logging_group}, extra={"group": logging_group},
) )
def execute_password_removal_action(
action: WorkflowAction,
document: Document | ConsumableDocument,
logging_group,
) -> None:
"""
Try to remove a password from a document using the configured list.
"""
passwords = action.passwords
if not passwords:
logger.warning(
"Password removal action %s has no passwords configured",
action.pk,
extra={"group": logging_group},
)
return
passwords = [
password.strip()
for password in re.split(r"[,\n]", passwords)
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
for password in passwords:
try:
remove_password(
[document.id],
password=password,
update_document=True,
user=document.owner,
)
logger.info(
"Removed password from document %s using workflow action %s",
document.pk,
action.pk,
extra={"group": logging_group},
)
return
except ValueError as e:
logger.warning(
"Password removal failed for document %s with supplied password: %s",
document.pk,
e,
extra={"group": logging_group},
)
logger.error(
"Password removal failed for document %s after trying all provided passwords",
document.pk,
extra={"group": logging_group},
)

View File

@@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-31 17:12+0000\n" "POT-Creation-Date: 2026-02-03 17:32+0000\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@@ -89,7 +89,7 @@ msgstr ""
msgid "Automatic" msgid "Automatic"
msgstr "" msgstr ""
#: documents/models.py:66 documents/models.py:444 documents/models.py:1646 #: documents/models.py:66 documents/models.py:444 documents/models.py:1659
#: paperless_mail/models.py:23 paperless_mail/models.py:143 #: paperless_mail/models.py:23 paperless_mail/models.py:143
msgid "name" msgid "name"
msgstr "" msgstr ""
@@ -252,7 +252,7 @@ msgid "The position of this document in your physical document archive."
msgstr "" msgstr ""
#: documents/models.py:313 documents/models.py:688 documents/models.py:742 #: documents/models.py:313 documents/models.py:688 documents/models.py:742
#: documents/models.py:1689 #: documents/models.py:1702
msgid "document" msgid "document"
msgstr "" msgstr ""
@@ -1089,183 +1089,197 @@ msgid "Webhook"
msgstr "" msgstr ""
#: documents/models.py:1410 #: documents/models.py:1410
msgid "Password removal"
msgstr ""
#: documents/models.py:1414
msgid "Workflow Action Type" msgid "Workflow Action Type"
msgstr "" msgstr ""
#: documents/models.py:1415 documents/models.py:1648 #: documents/models.py:1419 documents/models.py:1661
#: paperless_mail/models.py:145 #: paperless_mail/models.py:145
msgid "order" msgid "order"
msgstr "" msgstr ""
#: documents/models.py:1418 #: documents/models.py:1422
msgid "assign title" msgid "assign title"
msgstr "" msgstr ""
#: documents/models.py:1422 #: documents/models.py:1426
msgid "Assign a document title, must be a Jinja2 template, see documentation." msgid "Assign a document title, must be a Jinja2 template, see documentation."
msgstr "" msgstr ""
#: documents/models.py:1430 paperless_mail/models.py:274 #: documents/models.py:1434 paperless_mail/models.py:274
msgid "assign this tag" msgid "assign this tag"
msgstr "" msgstr ""
#: documents/models.py:1439 paperless_mail/models.py:282 #: documents/models.py:1443 paperless_mail/models.py:282
msgid "assign this document type" msgid "assign this document type"
msgstr "" msgstr ""
#: documents/models.py:1448 paperless_mail/models.py:296 #: documents/models.py:1452 paperless_mail/models.py:296
msgid "assign this correspondent" msgid "assign this correspondent"
msgstr "" msgstr ""
#: documents/models.py:1457 #: documents/models.py:1461
msgid "assign this storage path" msgid "assign this storage path"
msgstr "" msgstr ""
#: documents/models.py:1466 #: documents/models.py:1470
msgid "assign this owner" msgid "assign this owner"
msgstr "" msgstr ""
#: documents/models.py:1473 #: documents/models.py:1477
msgid "grant view permissions to these users" msgid "grant view permissions to these users"
msgstr "" msgstr ""
#: documents/models.py:1480 #: documents/models.py:1484
msgid "grant view permissions to these groups" msgid "grant view permissions to these groups"
msgstr "" msgstr ""
#: documents/models.py:1487 #: documents/models.py:1491
msgid "grant change permissions to these users" msgid "grant change permissions to these users"
msgstr "" msgstr ""
#: documents/models.py:1494 #: documents/models.py:1498
msgid "grant change permissions to these groups" msgid "grant change permissions to these groups"
msgstr "" msgstr ""
#: documents/models.py:1501 #: documents/models.py:1505
msgid "assign these custom fields" msgid "assign these custom fields"
msgstr "" msgstr ""
#: documents/models.py:1505 #: documents/models.py:1509
msgid "custom field values" msgid "custom field values"
msgstr "" msgstr ""
#: documents/models.py:1509 #: documents/models.py:1513
msgid "Optional values to assign to the custom fields." msgid "Optional values to assign to the custom fields."
msgstr "" msgstr ""
#: documents/models.py:1518 #: documents/models.py:1522
msgid "remove these tag(s)" msgid "remove these tag(s)"
msgstr "" msgstr ""
#: documents/models.py:1523 #: documents/models.py:1527
msgid "remove all tags" msgid "remove all tags"
msgstr "" msgstr ""
#: documents/models.py:1530 #: documents/models.py:1534
msgid "remove these document type(s)" msgid "remove these document type(s)"
msgstr "" msgstr ""
#: documents/models.py:1535 #: documents/models.py:1539
msgid "remove all document types" msgid "remove all document types"
msgstr "" msgstr ""
#: documents/models.py:1542 #: documents/models.py:1546
msgid "remove these correspondent(s)" msgid "remove these correspondent(s)"
msgstr "" msgstr ""
#: documents/models.py:1547 #: documents/models.py:1551
msgid "remove all correspondents" msgid "remove all correspondents"
msgstr "" msgstr ""
#: documents/models.py:1554 #: documents/models.py:1558
msgid "remove these storage path(s)" msgid "remove these storage path(s)"
msgstr "" msgstr ""
#: documents/models.py:1559 #: documents/models.py:1563
msgid "remove all storage paths" msgid "remove all storage paths"
msgstr "" msgstr ""
#: documents/models.py:1566 #: documents/models.py:1570
msgid "remove these owner(s)" msgid "remove these owner(s)"
msgstr "" msgstr ""
#: documents/models.py:1571 #: documents/models.py:1575
msgid "remove all owners" msgid "remove all owners"
msgstr "" msgstr ""
#: documents/models.py:1578 #: documents/models.py:1582
msgid "remove view permissions for these users" msgid "remove view permissions for these users"
msgstr "" msgstr ""
#: documents/models.py:1585 #: documents/models.py:1589
msgid "remove view permissions for these groups" msgid "remove view permissions for these groups"
msgstr "" msgstr ""
#: documents/models.py:1592 #: documents/models.py:1596
msgid "remove change permissions for these users" msgid "remove change permissions for these users"
msgstr "" msgstr ""
#: documents/models.py:1599 #: documents/models.py:1603
msgid "remove change permissions for these groups" msgid "remove change permissions for these groups"
msgstr "" msgstr ""
#: documents/models.py:1604 #: documents/models.py:1608
msgid "remove all permissions" msgid "remove all permissions"
msgstr "" msgstr ""
#: documents/models.py:1611 #: documents/models.py:1615
msgid "remove these custom fields" msgid "remove these custom fields"
msgstr "" msgstr ""
#: documents/models.py:1616 #: documents/models.py:1620
msgid "remove all custom fields" msgid "remove all custom fields"
msgstr "" msgstr ""
#: documents/models.py:1625 #: documents/models.py:1629
msgid "email" msgid "email"
msgstr "" msgstr ""
#: documents/models.py:1634 #: documents/models.py:1638
msgid "webhook" msgid "webhook"
msgstr "" msgstr ""
#: documents/models.py:1638 #: documents/models.py:1642
msgid "passwords"
msgstr ""
#: documents/models.py:1646
msgid ""
"Passwords to try when removing PDF protection. Separate with commas or new "
"lines."
msgstr ""
#: documents/models.py:1651
msgid "workflow action" msgid "workflow action"
msgstr "" msgstr ""
#: documents/models.py:1639 #: documents/models.py:1652
msgid "workflow actions" msgid "workflow actions"
msgstr "" msgstr ""
#: documents/models.py:1654 #: documents/models.py:1667
msgid "triggers" msgid "triggers"
msgstr "" msgstr ""
#: documents/models.py:1661 #: documents/models.py:1674
msgid "actions" msgid "actions"
msgstr "" msgstr ""
#: documents/models.py:1664 paperless_mail/models.py:154 #: documents/models.py:1677 paperless_mail/models.py:154
msgid "enabled" msgid "enabled"
msgstr "" msgstr ""
#: documents/models.py:1675 #: documents/models.py:1688
msgid "workflow" msgid "workflow"
msgstr "" msgstr ""
#: documents/models.py:1679 #: documents/models.py:1692
msgid "workflow trigger type" msgid "workflow trigger type"
msgstr "" msgstr ""
#: documents/models.py:1693 #: documents/models.py:1706
msgid "date run" msgid "date run"
msgstr "" msgstr ""
#: documents/models.py:1699 #: documents/models.py:1712
msgid "workflow run" msgid "workflow run"
msgstr "" msgstr ""
#: documents/models.py:1700 #: documents/models.py:1713
msgid "workflow runs" msgid "workflow runs"
msgstr "" msgstr ""