Coverage for documents/workflows/actions.py: 17%
108 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-10 09:07 +0000
1import logging
2import uuid
3from pathlib import Path
5from django.conf import settings
6from django.contrib.auth.models import User
7from django.utils import timezone
9from documents.data_models import ConsumableDocument
10from documents.data_models import DocumentMetadataOverrides
11from documents.mail import EmailAttachment
12from documents.mail import send_email
13from documents.models import Correspondent
14from documents.models import Document
15from documents.models import DocumentType
16from documents.models import WorkflowAction
17from documents.models import WorkflowTrigger
18from documents.plugins.base import StopConsumeTaskError
19from documents.signals import document_consumption_finished
20from documents.templating.workflows import parse_w_workflow_placeholders
21from documents.workflows.webhooks import send_webhook
23logger = logging.getLogger("paperless.workflows.actions")
26def build_workflow_action_context(
27 document: Document | ConsumableDocument,
28 overrides: DocumentMetadataOverrides | None,
29) -> dict:
30 """
31 Build context dictionary for workflow action placeholder parsing.
32 """
33 use_overrides = overrides is not None
35 if not use_overrides:
36 return {
37 "title": document.title,
38 "doc_url": f"{settings.PAPERLESS_URL}{settings.BASE_URL}documents/{document.pk}/",
39 "correspondent": document.correspondent.name
40 if document.correspondent
41 else "",
42 "document_type": document.document_type.name
43 if document.document_type
44 else "",
45 "owner_username": document.owner.username if document.owner else "",
46 "filename": document.original_filename or "",
47 "current_filename": document.filename or "",
48 "added": timezone.localtime(document.added),
49 "created": document.created,
50 "id": document.pk,
51 }
53 correspondent_obj = (
54 Correspondent.objects.filter(pk=overrides.correspondent_id).first()
55 if overrides and overrides.correspondent_id
56 else None
57 )
58 document_type_obj = (
59 DocumentType.objects.filter(pk=overrides.document_type_id).first()
60 if overrides and overrides.document_type_id
61 else None
62 )
63 owner_obj = (
64 User.objects.filter(pk=overrides.owner_id).first()
65 if overrides and overrides.owner_id
66 else None
67 )
69 filename = document.original_file if document.original_file else ""
70 return {
71 "title": overrides.title
72 if overrides and overrides.title
73 else str(document.original_file),
74 "doc_url": "",
75 "correspondent": correspondent_obj.name if correspondent_obj else "",
76 "document_type": document_type_obj.name if document_type_obj else "",
77 "owner_username": owner_obj.username if owner_obj else "",
78 "filename": filename,
79 "current_filename": filename,
80 "added": timezone.localtime(timezone.now()),
81 "created": overrides.created if overrides else None,
82 "id": "",
83 }
86def execute_email_action(
87 action: WorkflowAction,
88 document: Document | ConsumableDocument,
89 context: dict,
90 logging_group,
91 original_file: Path,
92 trigger_type: WorkflowTrigger.WorkflowTriggerType,
93) -> None:
94 """
95 Execute an email action for a workflow.
96 """
98 if not settings.EMAIL_ENABLED:
99 logger.error(
100 "Email backend has not been configured, cannot send email notifications",
101 extra={"group": logging_group},
102 )
103 return
105 try:
106 subject = (
107 parse_w_workflow_placeholders(
108 action.email.subject,
109 context["correspondent"],
110 context["document_type"],
111 context["owner_username"],
112 context["added"],
113 context["filename"],
114 context["current_filename"],
115 context["created"],
116 context["title"],
117 context["doc_url"],
118 context["id"],
119 )
120 if action.email.subject
121 else ""
122 )
123 body = (
124 parse_w_workflow_placeholders(
125 action.email.body,
126 context["correspondent"],
127 context["document_type"],
128 context["owner_username"],
129 context["added"],
130 context["filename"],
131 context["current_filename"],
132 context["created"],
133 context["title"],
134 context["doc_url"],
135 context["id"],
136 )
137 if action.email.body
138 else ""
139 )
141 attachments: list[EmailAttachment] = []
142 if action.email.include_document:
143 attachment: EmailAttachment | None = None
144 if trigger_type in [
145 WorkflowTrigger.WorkflowTriggerType.DOCUMENT_UPDATED,
146 WorkflowTrigger.WorkflowTriggerType.SCHEDULED,
147 ] and isinstance(document, Document):
148 friendly_name = (
149 Path(context["current_filename"]).name
150 if context["current_filename"]
151 else document.source_path.name
152 )
153 attachment = EmailAttachment(
154 path=document.source_path,
155 mime_type=document.mime_type,
156 friendly_name=friendly_name,
157 )
158 elif original_file:
159 friendly_name = (
160 Path(context["current_filename"]).name
161 if context["current_filename"]
162 else original_file.name
163 )
164 attachment = EmailAttachment(
165 path=original_file,
166 mime_type=document.mime_type,
167 friendly_name=friendly_name,
168 )
169 if attachment:
170 attachments = [attachment]
172 n_messages = send_email(
173 subject=subject,
174 body=body,
175 to=action.email.to.split(","),
176 attachments=attachments,
177 )
178 logger.debug(
179 f"Sent {n_messages} notification email(s) to {action.email.to}",
180 extra={"group": logging_group},
181 )
182 except Exception as e:
183 logger.exception(
184 f"Error occurred sending notification email: {e}",
185 extra={"group": logging_group},
186 )
189def execute_webhook_action(
190 action: WorkflowAction,
191 document: Document | ConsumableDocument,
192 context: dict,
193 logging_group,
194 original_file: Path,
195):
196 try:
197 data = {}
198 if action.webhook.use_params:
199 if action.webhook.params:
200 try:
201 for key, value in action.webhook.params.items():
202 data[key] = parse_w_workflow_placeholders(
203 value,
204 context["correspondent"],
205 context["document_type"],
206 context["owner_username"],
207 context["added"],
208 context["filename"],
209 context["current_filename"],
210 context["created"],
211 context["title"],
212 context["doc_url"],
213 context["id"],
214 )
215 except Exception as e:
216 logger.error(
217 f"Error occurred parsing webhook params: {e}",
218 extra={"group": logging_group},
219 )
220 elif action.webhook.body:
221 data = parse_w_workflow_placeholders(
222 action.webhook.body,
223 context["correspondent"],
224 context["document_type"],
225 context["owner_username"],
226 context["added"],
227 context["filename"],
228 context["current_filename"],
229 context["created"],
230 context["title"],
231 context["doc_url"],
232 context["id"],
233 )
234 headers = {}
235 if action.webhook.headers:
236 try:
237 headers = {str(k): str(v) for k, v in action.webhook.headers.items()}
238 except Exception as e:
239 logger.error(
240 f"Error occurred parsing webhook headers: {e}",
241 extra={"group": logging_group},
242 )
243 files = None
244 if action.webhook.include_document:
245 with original_file.open("rb") as f:
246 files = {
247 "file": (
248 str(context["filename"])
249 if context["filename"]
250 else original_file.name,
251 f.read(),
252 document.mime_type,
253 ),
254 }
255 send_webhook.apply_async(
256 kwargs={
257 "url": action.webhook.url,
258 "data": data,
259 "headers": headers,
260 "files": files,
261 "as_json": action.webhook.as_json,
262 },
263 )
264 logger.debug(
265 f"Webhook to {action.webhook.url} queued",
266 extra={"group": logging_group},
267 )
268 except Exception as e:
269 logger.exception(
270 f"Error occurred sending webhook: {e}",
271 extra={"group": logging_group},
272 )
275def execute_password_removal_action(
276 action: WorkflowAction,
277 document: Document | ConsumableDocument,
278 logging_group,
279 source_file: Path | None = None,
280) -> None:
281 """
282 Try to remove a password from a document using the configured list.
283 """
284 passwords = action.passwords
285 if not passwords:
286 logger.warning(
287 "Workflow action %s has no configured unlock values",
288 action.pk,
289 extra={"group": logging_group},
290 )
291 return
293 passwords = [p.strip() for p in passwords if p.strip()]
295 if isinstance(document, ConsumableDocument):
296 # hook the consumption-finished signal to attempt password removal later
297 def handler(sender, **kwargs):
298 consumed_document: Document = kwargs.get("document")
299 if consumed_document is not None:
300 execute_password_removal_action(
301 action,
302 consumed_document,
303 logging_group,
304 source_file=kwargs.get("original_file"),
305 )
306 document_consumption_finished.disconnect(handler)
308 document_consumption_finished.connect(handler, weak=False)
309 return
311 # import here to avoid circular dependency
312 from documents.bulk_edit import remove_password
314 for password in passwords:
315 try:
316 remove_password(
317 [document.id],
318 password=password,
319 update_document=True,
320 user=document.owner,
321 source_paths_by_id={document.id: source_file} if source_file else None,
322 )
323 logger.info(
324 "Unlocked document %s using workflow action %s",
325 document.pk,
326 action.pk,
327 extra={"group": logging_group},
328 )
329 return
330 except ValueError:
331 logger.warning(
332 "Workflow action %s could not unlock document %s with one configured value",
333 action.pk,
334 document.pk,
335 extra={"group": logging_group},
336 )
338 logger.error(
339 "Workflow action %s could not unlock document %s with any configured value",
340 action.pk,
341 document.pk,
342 extra={"group": logging_group},
343 )
346def execute_move_to_trash_action(
347 action: WorkflowAction,
348 document: Document | ConsumableDocument,
349 logging_group: uuid.UUID | None,
350) -> None:
351 """
352 Execute a move to trash action for a workflow on an existing document or a
353 document in consumption. In case of an existing document it soft-deletes
354 the document. In case of consumption it aborts consumption and deletes the
355 file.
356 """
357 if isinstance(document, Document):
358 document.delete()
359 logger.debug(
360 f"Moved document {document} to trash",
361 extra={"group": logging_group},
362 )
363 else:
364 if document.original_file.exists():
365 document.original_file.unlink()
366 logger.info(
367 f"Workflow move to trash action triggered during consumption, "
368 f"deleting file {document.original_file}",
369 extra={"group": logging_group},
370 )
371 raise StopConsumeTaskError(
372 "Document deleted by workflow action during consumption",
373 )