notifications.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611
  1. """API routes for notification providers."""
  2. import json
  3. import logging
  4. import time
  5. from collections import defaultdict, deque
  6. from datetime import datetime, timedelta, timezone
  7. from fastapi import APIRouter, Depends, HTTPException, Query
  8. from fastapi.responses import FileResponse
  9. from sqlalchemy import delete, desc, func, select
  10. from sqlalchemy.ext.asyncio import AsyncSession
  11. from backend.app.core.auth import RequirePermissionIfAuthEnabled, ScopedCaller, require_notification_send
  12. from backend.app.core.database import get_db
  13. from backend.app.core.permissions import Permission
  14. from backend.app.models.notification import NotificationLog, NotificationProvider
  15. from backend.app.models.user import User
  16. from backend.app.schemas.notification import (
  17. AppMessage,
  18. AppMessageChannel,
  19. AppMessageResult,
  20. NotificationLogResponse,
  21. NotificationLogStats,
  22. NotificationProviderCreate,
  23. NotificationProviderResponse,
  24. NotificationProviderUpdate,
  25. NotificationTestRequest,
  26. NotificationTestResponse,
  27. )
  28. from backend.app.services.notification_service import notification_service
  29. from backend.app.services.telegram_reactions import telegram_reaction_poller
  30. from backend.app.utils.notification_photos import find_notification_photo
  31. logger = logging.getLogger(__name__)
  32. router = APIRouter(prefix="/notifications", tags=["notifications"])
  33. async def _resync_reaction_poller():
  34. """Start/stop Telegram reaction polls after a provider changed (#3046).
  35. Never fails the request: the provider row is already saved, and the
  36. poller catches up on the next restart at worst.
  37. """
  38. try:
  39. await telegram_reaction_poller.sync()
  40. except Exception as e:
  41. logger.warning("Telegram reaction poller resync failed: %s", e)
  42. def _provider_to_dict(provider: NotificationProvider) -> dict:
  43. """Convert a NotificationProvider model to a response dictionary."""
  44. return {
  45. "id": provider.id,
  46. "name": provider.name,
  47. "provider_type": provider.provider_type,
  48. "enabled": provider.enabled,
  49. "config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
  50. "attach_photo": provider.attach_photo,
  51. # Print lifecycle events
  52. "on_print_start": provider.on_print_start,
  53. "on_print_complete": provider.on_print_complete,
  54. "on_print_failed": provider.on_print_failed,
  55. "on_print_stopped": provider.on_print_stopped,
  56. "on_print_progress": provider.on_print_progress,
  57. "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
  58. "on_billing_charge_failed": provider.on_billing_charge_failed,
  59. # Printer status events
  60. "on_printer_offline": provider.on_printer_offline,
  61. "on_printer_error": provider.on_printer_error,
  62. "on_ai_failure_detection": provider.on_ai_failure_detection,
  63. "on_filament_low": provider.on_filament_low,
  64. "on_maintenance_due": provider.on_maintenance_due,
  65. # Home Assistant sensor alerts (#1148, #2824). Both directions of this
  66. # file are hand-maintained field maps, so a column missing here reads
  67. # back as the schema default (False) no matter what the row holds.
  68. "on_ha_sensor_alert": provider.on_ha_sensor_alert,
  69. "on_location_ha_sensor_alert": provider.on_location_ha_sensor_alert,
  70. # AMS environmental alarms (regular AMS)
  71. "on_ams_humidity_high": provider.on_ams_humidity_high,
  72. "on_ams_temperature_high": provider.on_ams_temperature_high,
  73. "on_ams_drying_suspended": provider.on_ams_drying_suspended,
  74. # AMS-HT environmental alarms
  75. "on_ams_ht_humidity_high": provider.on_ams_ht_humidity_high,
  76. "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
  77. # Build plate detection
  78. "on_plate_not_empty": provider.on_plate_not_empty,
  79. "on_plate_clear_required": provider.on_plate_clear_required,
  80. # Post-print outcome confirmation (#1898)
  81. "on_print_confirm_request": provider.on_print_confirm_request,
  82. # Rows from before #3046 hold NULL here; "buttons" is what they did.
  83. "telegram_verdict_mode": provider.telegram_verdict_mode or "buttons",
  84. # Bed cooled
  85. "on_bed_cooled": provider.on_bed_cooled,
  86. # First layer complete
  87. "on_first_layer_complete": provider.on_first_layer_complete,
  88. # Messages from connected apps
  89. "on_app_message": bool(provider.on_app_message),
  90. # Inventory stock alerts. Absent here, the toggles above always read
  91. # back off no matter what the row holds — the same hand-maintained
  92. # field map the Home Assistant comment warns about.
  93. "on_stock_reorder_alert": provider.on_stock_reorder_alert,
  94. "on_stock_break_alert": provider.on_stock_break_alert,
  95. # Print queue events
  96. "on_queue_job_added": provider.on_queue_job_added,
  97. "on_queue_job_assigned": provider.on_queue_job_assigned,
  98. "on_queue_job_started": provider.on_queue_job_started,
  99. "on_queue_job_waiting": provider.on_queue_job_waiting,
  100. "on_queue_job_skipped": provider.on_queue_job_skipped,
  101. "on_queue_job_failed": provider.on_queue_job_failed,
  102. "on_queue_completed": provider.on_queue_completed,
  103. # Quiet hours
  104. "quiet_hours_enabled": provider.quiet_hours_enabled,
  105. "quiet_hours_start": provider.quiet_hours_start,
  106. "quiet_hours_end": provider.quiet_hours_end,
  107. # Daily digest
  108. "daily_digest_enabled": provider.daily_digest_enabled,
  109. "daily_digest_time": provider.daily_digest_time,
  110. # Printer filter
  111. "printer_id": provider.printer_id,
  112. # Status tracking
  113. "last_success": provider.last_success,
  114. "last_error": provider.last_error,
  115. "last_error_at": provider.last_error_at,
  116. # Timestamps
  117. "created_at": provider.created_at,
  118. "updated_at": provider.updated_at,
  119. }
  120. # ============================================================================
  121. # Provider List/Create Routes (no path parameters)
  122. # ============================================================================
  123. @router.get("/", response_model=list[NotificationProviderResponse])
  124. async def list_notification_providers(
  125. db: AsyncSession = Depends(get_db),
  126. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  127. ):
  128. """List all notification providers."""
  129. result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
  130. providers = result.scalars().all()
  131. return [_provider_to_dict(provider) for provider in providers]
  132. @router.post("/", response_model=NotificationProviderResponse)
  133. async def create_notification_provider(
  134. provider_data: NotificationProviderCreate,
  135. db: AsyncSession = Depends(get_db),
  136. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
  137. ):
  138. """Create a new notification provider."""
  139. provider = NotificationProvider(
  140. name=provider_data.name,
  141. provider_type=provider_data.provider_type.value,
  142. enabled=provider_data.enabled,
  143. config=json.dumps(provider_data.config),
  144. attach_photo=provider_data.attach_photo,
  145. # Print lifecycle events
  146. on_print_start=provider_data.on_print_start,
  147. on_print_complete=provider_data.on_print_complete,
  148. on_print_failed=provider_data.on_print_failed,
  149. on_print_stopped=provider_data.on_print_stopped,
  150. on_print_progress=provider_data.on_print_progress,
  151. on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
  152. on_billing_charge_failed=provider_data.on_billing_charge_failed,
  153. # Printer status events
  154. on_printer_offline=provider_data.on_printer_offline,
  155. on_printer_error=provider_data.on_printer_error,
  156. on_ai_failure_detection=provider_data.on_ai_failure_detection,
  157. on_filament_low=provider_data.on_filament_low,
  158. on_maintenance_due=provider_data.on_maintenance_due,
  159. # Home Assistant sensor alerts (#1148, #2824)
  160. on_ha_sensor_alert=provider_data.on_ha_sensor_alert,
  161. on_location_ha_sensor_alert=provider_data.on_location_ha_sensor_alert,
  162. # AMS environmental alarms (regular AMS)
  163. on_ams_humidity_high=provider_data.on_ams_humidity_high,
  164. on_ams_temperature_high=provider_data.on_ams_temperature_high,
  165. on_ams_drying_suspended=provider_data.on_ams_drying_suspended,
  166. # AMS-HT environmental alarms
  167. on_ams_ht_humidity_high=provider_data.on_ams_ht_humidity_high,
  168. on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
  169. # Build plate detection
  170. on_plate_not_empty=provider_data.on_plate_not_empty,
  171. on_plate_clear_required=provider_data.on_plate_clear_required,
  172. # Post-print outcome confirmation (#1898)
  173. on_print_confirm_request=provider_data.on_print_confirm_request,
  174. telegram_verdict_mode=provider_data.telegram_verdict_mode,
  175. # Bed cooled
  176. on_bed_cooled=provider_data.on_bed_cooled,
  177. # First layer complete
  178. on_first_layer_complete=provider_data.on_first_layer_complete,
  179. on_app_message=provider_data.on_app_message,
  180. # Inventory stock alerts
  181. on_stock_reorder_alert=provider_data.on_stock_reorder_alert,
  182. on_stock_break_alert=provider_data.on_stock_break_alert,
  183. # Print queue events
  184. on_queue_job_added=provider_data.on_queue_job_added,
  185. on_queue_job_assigned=provider_data.on_queue_job_assigned,
  186. on_queue_job_started=provider_data.on_queue_job_started,
  187. on_queue_job_waiting=provider_data.on_queue_job_waiting,
  188. on_queue_job_skipped=provider_data.on_queue_job_skipped,
  189. on_queue_job_failed=provider_data.on_queue_job_failed,
  190. on_queue_completed=provider_data.on_queue_completed,
  191. # Quiet hours
  192. quiet_hours_enabled=provider_data.quiet_hours_enabled,
  193. quiet_hours_start=provider_data.quiet_hours_start,
  194. quiet_hours_end=provider_data.quiet_hours_end,
  195. # Daily digest
  196. daily_digest_enabled=provider_data.daily_digest_enabled,
  197. daily_digest_time=provider_data.daily_digest_time,
  198. # Printer filter
  199. printer_id=provider_data.printer_id,
  200. )
  201. db.add(provider)
  202. await db.commit()
  203. await db.refresh(provider)
  204. logger.info("Created notification provider: %s (%s)", provider.name, provider.provider_type)
  205. await _resync_reaction_poller()
  206. return _provider_to_dict(provider)
  207. # ============================================================================
  208. # Static Path Routes (must come BEFORE parameterized routes)
  209. # ============================================================================
  210. @router.post("/test-config", response_model=NotificationTestResponse)
  211. async def test_notification_config(
  212. test_request: NotificationTestRequest,
  213. db: AsyncSession = Depends(get_db),
  214. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
  215. ):
  216. """Test notification configuration before saving."""
  217. success, message = await notification_service.send_test_notification(
  218. test_request.provider_type.value, test_request.config, db, attach_photo=test_request.attach_photo
  219. )
  220. return NotificationTestResponse(success=success, message=message)
  221. @router.post("/test-all")
  222. async def test_all_notification_providers(
  223. db: AsyncSession = Depends(get_db),
  224. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  225. ):
  226. """Send a test notification to all enabled providers."""
  227. result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
  228. providers = result.scalars().all()
  229. if not providers:
  230. return {"tested": 0, "success": 0, "failed": 0, "results": []}
  231. results = []
  232. success_count = 0
  233. failed_count = 0
  234. for provider in providers:
  235. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  236. success, message = await notification_service.send_test_notification(
  237. provider.provider_type, config, db, attach_photo=provider.attach_photo
  238. )
  239. # Update provider status
  240. if success:
  241. provider.last_success = datetime.now(timezone.utc)
  242. success_count += 1
  243. else:
  244. provider.last_error = message
  245. provider.last_error_at = datetime.now(timezone.utc)
  246. failed_count += 1
  247. results.append(
  248. {
  249. "provider_id": provider.id,
  250. "provider_name": provider.name,
  251. "provider_type": provider.provider_type,
  252. "success": success,
  253. "message": message,
  254. }
  255. )
  256. await db.commit()
  257. return {
  258. "tested": len(providers),
  259. "success": success_count,
  260. "failed": failed_count,
  261. "results": results,
  262. }
  263. # ============================================================================
  264. # Notification Log Routes (must come BEFORE /{provider_id} routes)
  265. # ============================================================================
  266. @router.get("/logs", response_model=list[NotificationLogResponse])
  267. async def get_notification_logs(
  268. limit: int = Query(default=100, ge=1, le=500),
  269. offset: int = Query(default=0, ge=0),
  270. provider_id: int | None = Query(default=None),
  271. event_type: str | None = Query(default=None),
  272. success: bool | None = Query(default=None),
  273. days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
  274. db: AsyncSession = Depends(get_db),
  275. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  276. ):
  277. """Get notification logs with optional filters."""
  278. query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
  279. # Apply filters
  280. if provider_id is not None:
  281. query = query.where(NotificationLog.provider_id == provider_id)
  282. if event_type is not None:
  283. query = query.where(NotificationLog.event_type == event_type)
  284. if success is not None:
  285. query = query.where(NotificationLog.success == success)
  286. if days is not None:
  287. cutoff = datetime.now(timezone.utc) - timedelta(days=days)
  288. query = query.where(NotificationLog.created_at >= cutoff)
  289. query = query.offset(offset).limit(limit)
  290. result = await db.execute(query)
  291. logs = result.scalars().all()
  292. # Get provider info for each log
  293. response = []
  294. providers_cache: dict[int, NotificationProvider | None] = {}
  295. for log in logs:
  296. if log.provider_id not in providers_cache:
  297. provider_result = await db.execute(
  298. select(NotificationProvider).where(NotificationProvider.id == log.provider_id)
  299. )
  300. providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
  301. provider = providers_cache[log.provider_id]
  302. response.append(
  303. NotificationLogResponse(
  304. id=log.id,
  305. provider_id=log.provider_id,
  306. provider_name=provider.name if provider else None,
  307. provider_type=provider.provider_type if provider else None,
  308. event_type=log.event_type,
  309. title=log.title,
  310. message=log.message,
  311. success=log.success,
  312. error_message=log.error_message,
  313. printer_id=log.printer_id,
  314. printer_name=log.printer_name,
  315. created_at=log.created_at,
  316. )
  317. )
  318. return response
  319. @router.get("/logs/stats", response_model=NotificationLogStats)
  320. async def get_notification_log_stats(
  321. days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
  322. db: AsyncSession = Depends(get_db),
  323. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  324. ):
  325. """Get notification log statistics."""
  326. cutoff = datetime.now(timezone.utc) - timedelta(days=days)
  327. # Total counts
  328. total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
  329. total = total_result.scalar() or 0
  330. success_result = await db.execute(
  331. select(func.count(NotificationLog.id)).where(
  332. NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)
  333. )
  334. )
  335. success_count = success_result.scalar() or 0
  336. # By event type
  337. event_result = await db.execute(
  338. select(NotificationLog.event_type, func.count(NotificationLog.id))
  339. .where(NotificationLog.created_at >= cutoff)
  340. .group_by(NotificationLog.event_type)
  341. )
  342. by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
  343. # By provider (need to join to get name)
  344. provider_result = await db.execute(
  345. select(NotificationProvider.name, func.count(NotificationLog.id))
  346. .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
  347. .where(NotificationLog.created_at >= cutoff)
  348. .group_by(NotificationProvider.name)
  349. )
  350. by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
  351. return NotificationLogStats(
  352. total=total,
  353. success_count=success_count,
  354. failure_count=total - success_count,
  355. by_event_type=by_event_type,
  356. by_provider=by_provider,
  357. )
  358. @router.delete("/logs")
  359. async def clear_notification_logs(
  360. older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
  361. db: AsyncSession = Depends(get_db),
  362. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
  363. ):
  364. """Clear old notification logs."""
  365. cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
  366. result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
  367. await db.commit()
  368. deleted_count = result.rowcount
  369. logger.info("Deleted %s notification logs older than %s days", deleted_count, older_than_days)
  370. return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
  371. @router.get("/photos/{filename}")
  372. async def get_notification_photo(filename: str):
  373. """Serve an ad-hoc notification snapshot to HA, Bark or Slack.
  374. They fetch this URL themselves with no session, so the unguessable
  375. filename is the credential and opens this one photo only -- see
  376. backend/app/utils/notification_photos.py. Anything that isn't a live
  377. photo of exactly that shape is a 404.
  378. """
  379. photo_path = find_notification_photo(filename)
  380. if photo_path is None:
  381. raise HTTPException(404, "Photo not found")
  382. return FileResponse(path=photo_path, media_type="image/jpeg", headers={"Cache-Control": "private, no-store"})
  383. # ============================================================================
  384. # Provider Instance Routes (parameterized - must come LAST)
  385. # ============================================================================
  386. # Messages from other applications -------------------------------------------
  387. # Per caller, in memory: enough for any real app (Bambuddy Orders sends a few a
  388. # day), and a buggy or hostile one can't flood the channels.
  389. APP_MESSAGE_LIMIT = 20
  390. APP_MESSAGE_WINDOW_SECONDS = 60
  391. _app_message_times: dict[str, deque[float]] = defaultdict(deque)
  392. def _app_sender(caller: ScopedCaller) -> tuple[str, str]:
  393. """(rate-limit key, name shown in the log) for whoever sends the message."""
  394. if caller.api_key is not None:
  395. return f"key:{caller.api_key.id}", caller.api_key.name
  396. if caller.user is not None:
  397. return f"user:{caller.user.id}", caller.user.username
  398. return "anonymous", "app"
  399. def _check_app_message_rate(key: str) -> None:
  400. times = _app_message_times[key]
  401. cutoff = time.monotonic() - APP_MESSAGE_WINDOW_SECONDS
  402. while times and times[0] < cutoff:
  403. times.popleft()
  404. if len(times) >= APP_MESSAGE_LIMIT:
  405. raise HTTPException(status_code=429, detail="Too many messages; try again in a minute")
  406. times.append(time.monotonic())
  407. @router.post("/app-message", response_model=AppMessageResult)
  408. async def send_app_message(
  409. data: AppMessage,
  410. db: AsyncSession = Depends(get_db),
  411. caller: ScopedCaller = Depends(require_notification_send()),
  412. ):
  413. """Send a message through every enabled channel that has "Messages from
  414. connected apps" on. For other applications, e.g. Bambuddy Orders; an API
  415. key needs the "Send notifications" permission."""
  416. key, sender = _app_sender(caller)
  417. _check_app_message_rate(key)
  418. channels = await notification_service.on_app_message(
  419. db, sender=sender, title=data.title, message=data.message, url=data.url
  420. )
  421. return AppMessageResult(channels=channels)
  422. @router.get("/app-message/channels", response_model=list[AppMessageChannel])
  423. async def app_message_channels(
  424. db: AsyncSession = Depends(get_db),
  425. _: ScopedCaller = Depends(require_notification_send()),
  426. ):
  427. """The enabled channels that deliver app messages: names and types only,
  428. so an app can tell its user where its messages will arrive."""
  429. rows = await db.execute(
  430. select(NotificationProvider)
  431. .where(NotificationProvider.enabled.is_(True), NotificationProvider.on_app_message.is_(True))
  432. .order_by(NotificationProvider.name)
  433. )
  434. return [AppMessageChannel(name=p.name, provider_type=p.provider_type) for p in rows.scalars()]
  435. @router.get("/{provider_id}", response_model=NotificationProviderResponse)
  436. async def get_notification_provider(
  437. provider_id: int,
  438. db: AsyncSession = Depends(get_db),
  439. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  440. ):
  441. """Get a specific notification provider."""
  442. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  443. provider = result.scalar_one_or_none()
  444. if not provider:
  445. raise HTTPException(status_code=404, detail="Notification provider not found")
  446. return _provider_to_dict(provider)
  447. @router.patch("/{provider_id}", response_model=NotificationProviderResponse)
  448. async def update_notification_provider(
  449. provider_id: int,
  450. update_data: NotificationProviderUpdate,
  451. db: AsyncSession = Depends(get_db),
  452. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  453. ):
  454. """Update a notification provider."""
  455. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  456. provider = result.scalar_one_or_none()
  457. if not provider:
  458. raise HTTPException(status_code=404, detail="Notification provider not found")
  459. # Update only provided fields
  460. update_dict = update_data.model_dump(exclude_unset=True)
  461. for key, value in update_dict.items():
  462. if key == "config" and value is not None:
  463. setattr(provider, key, json.dumps(value))
  464. elif key == "provider_type" and value is not None:
  465. setattr(provider, key, value.value)
  466. else:
  467. setattr(provider, key, value)
  468. await db.commit()
  469. await db.refresh(provider)
  470. logger.info("Updated notification provider: %s", provider.name)
  471. await _resync_reaction_poller()
  472. return _provider_to_dict(provider)
  473. @router.delete("/{provider_id}")
  474. async def delete_notification_provider(
  475. provider_id: int,
  476. db: AsyncSession = Depends(get_db),
  477. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
  478. ):
  479. """Delete a notification provider."""
  480. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  481. provider = result.scalar_one_or_none()
  482. if not provider:
  483. raise HTTPException(status_code=404, detail="Notification provider not found")
  484. name = provider.name
  485. await db.delete(provider)
  486. await db.commit()
  487. logger.info("Deleted notification provider: %s", name)
  488. await _resync_reaction_poller()
  489. return {"message": f"Notification provider '{name}' deleted"}
  490. @router.post("/{provider_id}/test", response_model=NotificationTestResponse)
  491. async def test_notification_provider(
  492. provider_id: int,
  493. db: AsyncSession = Depends(get_db),
  494. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  495. ):
  496. """Send a test notification using an existing provider."""
  497. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  498. provider = result.scalar_one_or_none()
  499. if not provider:
  500. raise HTTPException(status_code=404, detail="Notification provider not found")
  501. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  502. success, message = await notification_service.send_test_notification(
  503. provider.provider_type, config, db, attach_photo=provider.attach_photo
  504. )
  505. # Update provider status
  506. if success:
  507. provider.last_success = datetime.now(timezone.utc)
  508. else:
  509. provider.last_error = message
  510. provider.last_error_at = datetime.now(timezone.utc)
  511. await db.commit()
  512. return NotificationTestResponse(success=success, message=message)