Coverage for src/local_deep_research/notifications/queue_helpers.py: 96%

127 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1""" 

2Queue notification helpers for the notification system. 

3 

4Provides helper functions for sending queue-related notifications 

5to keep the queue manager focused on queue logic. 

6""" 

7 

8from typing import Any, Dict, Optional 

9from loguru import logger 

10from sqlalchemy.orm import Session 

11 

12from .exceptions import RateLimitError 

13from .manager import NotificationManager, NotificationResult 

14from .templates import EventType 

15 

16 

17def _format_reason(result: Any) -> str: 

18 """Render a ``send_notification`` outcome as ``"(reason: detail)"``, 

19 falling back to ``bool(result)`` so legacy mocks / older callers that 

20 still return a bare ``True``/``False`` keep working. 

21 """ 

22 if isinstance(result, NotificationResult): 

23 if result.detail: 23 ↛ 25line 23 didn't jump to line 25 because the condition on line 23 was always true

24 return f"({result.reason.value}: {result.detail})" 

25 return f"({result.reason.value})" 

26 return f"({'sent' if bool(result) else 'dropped'})" 

27 

28 

29def send_queue_notification( 

30 username: str, 

31 research_id: str, 

32 query: str, 

33 settings_snapshot: Dict[str, Any], 

34 position: Optional[int] = None, 

35) -> bool: 

36 """ 

37 Send a research queued notification. 

38 

39 Args: 

40 username: User who owns the research 

41 research_id: UUID of the research 

42 query: Research query string 

43 settings_snapshot: Settings snapshot for thread-safe access 

44 position: Queue position (optional) 

45 

46 Returns: 

47 True if notification was sent successfully, False otherwise. 

48 Returned as ``bool`` for backward compatibility; the precise 

49 reason for a falsy return is logged via ``NotificationResult``. 

50 """ 

51 try: 

52 notification_manager = NotificationManager( 

53 settings_snapshot=settings_snapshot, user_id=username 

54 ) 

55 

56 # Build notification context 

57 context: Dict[str, Any] = { 

58 "query": query, 

59 "research_id": research_id, 

60 } 

61 

62 if position is not None: 

63 context["position"] = position 

64 context["wait_time"] = ( 

65 "Unknown" # Could estimate based on active researches 

66 ) 

67 

68 result = notification_manager.send_notification( 

69 event_type=EventType.RESEARCH_QUEUED, 

70 context=context, 

71 ) 

72 if not result: 

73 logger.debug( 

74 f"Queue notification not sent for {research_id} " 

75 f"{_format_reason(result)}" 

76 ) 

77 return bool(getattr(result, "sent", result)) 

78 

79 except RateLimitError: 

80 logger.warning( 

81 f"Rate limited sending queued notification for {research_id}" 

82 ) 

83 return False 

84 except Exception: 

85 logger.warning(f"Failed to send queued notification for {research_id}") 

86 return False 

87 

88 

89def send_queue_failed_notification( 

90 username: str, 

91 research_id: str, 

92 query: str, 

93 error_message: Optional[str] = None, 

94 settings_snapshot: Optional[Dict[str, Any]] = None, 

95) -> bool: 

96 """ 

97 Send a research failed notification from queue operations. 

98 

99 Args: 

100 username: User who owns the research 

101 research_id: UUID of the research 

102 query: Research query string 

103 error_message: Optional error message 

104 settings_snapshot: Settings snapshot for thread-safe access 

105 

106 Returns: 

107 True if notification was sent successfully, False otherwise. 

108 Returned as ``bool`` for backward compatibility; the precise 

109 reason for a falsy return is logged via ``NotificationResult``. 

110 """ 

111 if not settings_snapshot: 

112 logger.debug("No settings snapshot provided for failed notification") 

113 return False 

114 

115 try: 

116 notification_manager = NotificationManager( 

117 settings_snapshot=settings_snapshot, user_id=username 

118 ) 

119 

120 # Build notification context 

121 context = { 

122 "query": query, 

123 "research_id": research_id, 

124 } 

125 

126 if error_message: 

127 context["error"] = error_message 

128 

129 result = notification_manager.send_notification( 

130 event_type=EventType.RESEARCH_FAILED, 

131 context=context, 

132 ) 

133 if not result: 

134 logger.debug( 

135 f"Failed-notification not sent for {research_id} " 

136 f"{_format_reason(result)}" 

137 ) 

138 return bool(getattr(result, "sent", result)) 

139 

140 except RateLimitError: 

141 logger.warning( 

142 f"Rate limited sending failed notification for {research_id}" 

143 ) 

144 return False 

145 except Exception: 

146 logger.warning(f"Failed to send failed notification for {research_id}") 

147 return False 

148 

149 

150def send_queue_failed_notification_from_session( 

151 username: str, 

152 research_id: str, 

153 query: str, 

154 error_message: str, 

155 db_session: Session, 

156) -> None: 

157 """ 

158 Send a research failed notification, fetching settings from db_session. 

159 

160 This is a convenience wrapper for the queue processor that handles 

161 settings snapshot retrieval, logging, and error handling internally. 

162 All notification logic is contained within this function. 

163 

164 Args: 

165 username: User who owns the research 

166 research_id: UUID of the research 

167 query: Research query string 

168 error_message: Error message to include in notification 

169 db_session: Database session to fetch settings from 

170 """ 

171 try: 

172 from ..settings import SettingsManager 

173 

174 # Get settings snapshot from database session 

175 settings_manager = SettingsManager(db_session) 

176 settings_snapshot = settings_manager.get_settings_snapshot() 

177 

178 # Send notification (handles RateLimitError internally, returns False) 

179 success = send_queue_failed_notification( 

180 username=username, 

181 research_id=research_id, 

182 query=query, 

183 error_message=error_message, 

184 settings_snapshot=settings_snapshot, 

185 ) 

186 

187 if success: 

188 logger.info(f"Sent failure notification for research {research_id}") 

189 else: 

190 logger.warning( 

191 f"Failed to send failure notification for {research_id} (not sent)" 

192 ) 

193 except Exception: 

194 logger.exception( 

195 f"Failed to send failure notification for {research_id}" 

196 ) 

197 

198 

199def send_research_completed_notification_from_session( 

200 username: str, 

201 research_id: str, 

202 db_session: Session, 

203) -> None: 

204 """ 

205 Send research completed notification with summary and URL. 

206 

207 This is a convenience wrapper for the queue processor that handles 

208 all notification logic for completed research, including: 

209 - Research database lookup 

210 - Report content retrieval 

211 - URL building 

212 - Context building with summary 

213 - All logging and error handling 

214 

215 Args: 

216 username: User who owns the research 

217 research_id: UUID of the research 

218 db_session: Database session to fetch research and settings from 

219 """ 

220 try: 

221 logger.info( 

222 f"Starting completed notification process for research {research_id}, " 

223 f"user {username}" 

224 ) 

225 

226 # Import here to avoid circular dependencies 

227 from ..database.models import ResearchHistory 

228 from ..settings import SettingsManager 

229 from .manager import NotificationManager 

230 from .url_builder import build_notification_url 

231 

232 # Get research details for notification 

233 research = ( 

234 db_session.query(ResearchHistory).filter_by(id=research_id).first() 

235 ) 

236 

237 # Get settings snapshot for thread-safe notification sending 

238 settings_manager = SettingsManager(db_session) 

239 settings_snapshot = settings_manager.get_settings_snapshot() 

240 

241 if research: 

242 logger.info( 

243 f"Found research record, creating NotificationManager " 

244 f"for user {username}" 

245 ) 

246 

247 # Create notification manager with settings snapshot 

248 notification_manager = NotificationManager( 

249 settings_snapshot=settings_snapshot, user_id=username 

250 ) 

251 

252 # Build full URL for notification 

253 full_url = build_notification_url( 

254 f"/research/{research_id}", 

255 settings_manager=settings_manager, 

256 ) 

257 

258 # Build notification context with required fields 

259 context = { 

260 "query": research.query or "Unknown query", 

261 "research_id": research_id, 

262 "summary": "No summary available", 

263 "url": full_url, 

264 } 

265 

266 # Get report content for notification 

267 from ..storage import get_report_storage 

268 

269 storage = get_report_storage(session=db_session) 

270 report_content = storage.get_report(research_id) 

271 

272 if report_content: 

273 # Truncate summary if too long 

274 context["summary"] = ( 

275 report_content[:200] + "..." 

276 if len(report_content) > 200 

277 else report_content 

278 ) 

279 

280 logger.info( 

281 f"Sending RESEARCH_COMPLETED notification for research " 

282 f"{research_id} to user {username}" 

283 ) 

284 logger.debug(f"Notification context: {context}") 

285 

286 # Send notification using the manager 

287 result = notification_manager.send_notification( 

288 event_type=EventType.RESEARCH_COMPLETED, 

289 context=context, 

290 ) 

291 

292 if result: 

293 logger.info( 

294 f"Successfully sent completion notification for research {research_id}" 

295 ) 

296 else: 

297 logger.warning( 

298 f"Completion notification not sent for {research_id} " 

299 f"{_format_reason(result)}" 

300 ) 

301 

302 else: 

303 logger.warning( 

304 f"Could not find research {research_id} in database, " 

305 f"sending notification with minimal details" 

306 ) 

307 

308 # Create notification manager with settings snapshot 

309 notification_manager = NotificationManager( 

310 settings_snapshot=settings_snapshot, user_id=username 

311 ) 

312 

313 # Build minimal context 

314 context = { 

315 "query": f"Research {research_id}", 

316 "research_id": research_id, 

317 "summary": "Research completed but details unavailable", 

318 "url": f"/research/{research_id}", 

319 } 

320 

321 minimal_result = notification_manager.send_notification( 

322 event_type=EventType.RESEARCH_COMPLETED, 

323 context=context, 

324 ) 

325 if minimal_result: 

326 logger.info( 

327 f"Sent completion notification for research {research_id} " 

328 f"(minimal details)" 

329 ) 

330 else: 

331 logger.warning( 

332 f"Completion notification not sent for {research_id} " 

333 f"(minimal details) {_format_reason(minimal_result)}" 

334 ) 

335 

336 except RateLimitError: 

337 logger.warning( 

338 f"Rate limited sending completion notification for {research_id}" 

339 ) 

340 except Exception: 

341 logger.exception( 

342 f"Failed to send completion notification for {research_id}" 

343 ) 

344 

345 

346def send_research_failed_notification_from_session( 

347 username: str, 

348 research_id: str, 

349 error_message: str, 

350 db_session: Session, 

351) -> None: 

352 """ 

353 Send research failed notification (research-specific version). 

354 

355 This is a convenience wrapper for the queue processor that handles 

356 all notification logic for failed research, including: 

357 - Research database lookup to get query 

358 - Context building with sanitized error message 

359 - All logging and error handling 

360 

361 Args: 

362 username: User who owns the research 

363 research_id: UUID of the research 

364 error_message: Error message (will be sanitized for security) 

365 db_session: Database session to fetch research and settings from 

366 """ 

367 try: 

368 logger.info( 

369 f"Starting failed notification process for research {research_id}, " 

370 f"user {username}" 

371 ) 

372 

373 # Import here to avoid circular dependencies 

374 from ..database.models import ResearchHistory 

375 from ..settings import SettingsManager 

376 from .manager import NotificationManager 

377 

378 # Get research details for notification 

379 research = ( 

380 db_session.query(ResearchHistory).filter_by(id=research_id).first() 

381 ) 

382 

383 # Get settings snapshot for thread-safe notification sending 

384 settings_manager = SettingsManager(db_session) 

385 settings_snapshot = settings_manager.get_settings_snapshot() 

386 

387 # Sanitize error message for notification to avoid exposing 

388 # sensitive information (as noted by github-advanced-security) 

389 safe_error = "Research failed. Check logs for details." 

390 

391 if research: 

392 logger.info( 

393 f"Found research record, creating NotificationManager " 

394 f"for user {username}" 

395 ) 

396 

397 # Create notification manager with settings snapshot 

398 notification_manager = NotificationManager( 

399 settings_snapshot=settings_snapshot, user_id=username 

400 ) 

401 

402 # Build notification context 

403 context = { 

404 "query": research.query or "Unknown query", 

405 "research_id": research_id, 

406 "error": safe_error, 

407 } 

408 

409 logger.info( 

410 f"Sending RESEARCH_FAILED notification for research " 

411 f"{research_id} to user {username}" 

412 ) 

413 logger.debug(f"Notification context: {context}") 

414 

415 # Send notification using the manager 

416 result = notification_manager.send_notification( 

417 event_type=EventType.RESEARCH_FAILED, 

418 context=context, 

419 ) 

420 

421 if result: 421 ↛ 426line 421 didn't jump to line 426 because the condition on line 421 was always true

422 logger.info( 

423 f"Successfully sent failure notification for research {research_id}" 

424 ) 

425 else: 

426 logger.warning( 

427 f"Failure notification not sent for {research_id} " 

428 f"{_format_reason(result)}" 

429 ) 

430 

431 else: 

432 logger.warning( 

433 f"Could not find research {research_id} in database, " 

434 f"sending notification with minimal details" 

435 ) 

436 

437 # Create notification manager with settings snapshot 

438 notification_manager = NotificationManager( 

439 settings_snapshot=settings_snapshot, user_id=username 

440 ) 

441 

442 # Build minimal context 

443 context = { 

444 "query": f"Research {research_id}", 

445 "research_id": research_id, 

446 "error": safe_error, 

447 } 

448 

449 minimal_result = notification_manager.send_notification( 

450 event_type=EventType.RESEARCH_FAILED, 

451 context=context, 

452 ) 

453 if minimal_result: 

454 logger.info( 

455 f"Sent failure notification for research {research_id} " 

456 f"(minimal details)" 

457 ) 

458 else: 

459 logger.warning( 

460 f"Failure notification not sent for {research_id} " 

461 f"(minimal details) {_format_reason(minimal_result)}" 

462 ) 

463 

464 except RateLimitError: 

465 logger.warning( 

466 f"Rate limited sending failure notification for {research_id}" 

467 ) 

468 except Exception: 

469 logger.exception( 

470 f"Failed to send failure notification for {research_id}" 

471 )