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

1import logging 

2import uuid 

3from pathlib import Path 

4 

5from django.conf import settings 

6from django.contrib.auth.models import User 

7from django.utils import timezone 

8 

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 

22 

23logger = logging.getLogger("paperless.workflows.actions") 

24 

25 

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 

34 

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 } 

52 

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 ) 

68 

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 } 

84 

85 

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 """ 

97 

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 

104 

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 ) 

140 

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] 

171 

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 ) 

187 

188 

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 ) 

273 

274 

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 

292 

293 passwords = [p.strip() for p in passwords if p.strip()] 

294 

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) 

307 

308 document_consumption_finished.connect(handler, weak=False) 

309 return 

310 

311 # import here to avoid circular dependency 

312 from documents.bulk_edit import remove_password 

313 

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 ) 

337 

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 ) 

344 

345 

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 )