notifications.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480
  1. """API routes for notification providers."""
  2. import json
  3. import logging
  4. from datetime import datetime, timedelta, timezone
  5. from fastapi import APIRouter, Depends, HTTPException, Query
  6. from sqlalchemy import delete, desc, func, select
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from backend.app.core.auth import RequirePermissionIfAuthEnabled
  9. from backend.app.core.database import get_db
  10. from backend.app.core.permissions import Permission
  11. from backend.app.models.notification import NotificationLog, NotificationProvider
  12. from backend.app.models.user import User
  13. from backend.app.schemas.notification import (
  14. NotificationLogResponse,
  15. NotificationLogStats,
  16. NotificationProviderCreate,
  17. NotificationProviderResponse,
  18. NotificationProviderUpdate,
  19. NotificationTestRequest,
  20. NotificationTestResponse,
  21. )
  22. from backend.app.services.notification_service import notification_service
  23. logger = logging.getLogger(__name__)
  24. router = APIRouter(prefix="/notifications", tags=["notifications"])
  25. def _provider_to_dict(provider: NotificationProvider) -> dict:
  26. """Convert a NotificationProvider model to a response dictionary."""
  27. return {
  28. "id": provider.id,
  29. "name": provider.name,
  30. "provider_type": provider.provider_type,
  31. "enabled": provider.enabled,
  32. "config": json.loads(provider.config) if isinstance(provider.config, str) else provider.config,
  33. # Print lifecycle events
  34. "on_print_start": provider.on_print_start,
  35. "on_print_complete": provider.on_print_complete,
  36. "on_print_failed": provider.on_print_failed,
  37. "on_print_stopped": provider.on_print_stopped,
  38. "on_print_progress": provider.on_print_progress,
  39. "on_print_missing_spool_assignment": provider.on_print_missing_spool_assignment,
  40. "on_billing_charge_failed": provider.on_billing_charge_failed,
  41. # Printer status events
  42. "on_printer_offline": provider.on_printer_offline,
  43. "on_printer_error": provider.on_printer_error,
  44. "on_ai_failure_detection": provider.on_ai_failure_detection,
  45. "on_filament_low": provider.on_filament_low,
  46. "on_maintenance_due": provider.on_maintenance_due,
  47. # AMS environmental alarms (regular AMS)
  48. "on_ams_humidity_high": provider.on_ams_humidity_high,
  49. "on_ams_temperature_high": provider.on_ams_temperature_high,
  50. "on_ams_drying_suspended": provider.on_ams_drying_suspended,
  51. # AMS-HT environmental alarms
  52. "on_ams_ht_humidity_high": provider.on_ams_ht_humidity_high,
  53. "on_ams_ht_temperature_high": provider.on_ams_ht_temperature_high,
  54. # Build plate detection
  55. "on_plate_not_empty": provider.on_plate_not_empty,
  56. "on_plate_clear_required": provider.on_plate_clear_required,
  57. # Bed cooled
  58. "on_bed_cooled": provider.on_bed_cooled,
  59. # First layer complete
  60. "on_first_layer_complete": provider.on_first_layer_complete,
  61. # Print queue events
  62. "on_queue_job_added": provider.on_queue_job_added,
  63. "on_queue_job_assigned": provider.on_queue_job_assigned,
  64. "on_queue_job_started": provider.on_queue_job_started,
  65. "on_queue_job_waiting": provider.on_queue_job_waiting,
  66. "on_queue_job_skipped": provider.on_queue_job_skipped,
  67. "on_queue_job_failed": provider.on_queue_job_failed,
  68. "on_queue_completed": provider.on_queue_completed,
  69. # Quiet hours
  70. "quiet_hours_enabled": provider.quiet_hours_enabled,
  71. "quiet_hours_start": provider.quiet_hours_start,
  72. "quiet_hours_end": provider.quiet_hours_end,
  73. # Daily digest
  74. "daily_digest_enabled": provider.daily_digest_enabled,
  75. "daily_digest_time": provider.daily_digest_time,
  76. # Printer filter
  77. "printer_id": provider.printer_id,
  78. # Status tracking
  79. "last_success": provider.last_success,
  80. "last_error": provider.last_error,
  81. "last_error_at": provider.last_error_at,
  82. # Timestamps
  83. "created_at": provider.created_at,
  84. "updated_at": provider.updated_at,
  85. }
  86. # ============================================================================
  87. # Provider List/Create Routes (no path parameters)
  88. # ============================================================================
  89. @router.get("/", response_model=list[NotificationProviderResponse])
  90. async def list_notification_providers(
  91. db: AsyncSession = Depends(get_db),
  92. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  93. ):
  94. """List all notification providers."""
  95. result = await db.execute(select(NotificationProvider).order_by(NotificationProvider.created_at.desc()))
  96. providers = result.scalars().all()
  97. return [_provider_to_dict(provider) for provider in providers]
  98. @router.post("/", response_model=NotificationProviderResponse)
  99. async def create_notification_provider(
  100. provider_data: NotificationProviderCreate,
  101. db: AsyncSession = Depends(get_db),
  102. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
  103. ):
  104. """Create a new notification provider."""
  105. provider = NotificationProvider(
  106. name=provider_data.name,
  107. provider_type=provider_data.provider_type.value,
  108. enabled=provider_data.enabled,
  109. config=json.dumps(provider_data.config),
  110. # Print lifecycle events
  111. on_print_start=provider_data.on_print_start,
  112. on_print_complete=provider_data.on_print_complete,
  113. on_print_failed=provider_data.on_print_failed,
  114. on_print_stopped=provider_data.on_print_stopped,
  115. on_print_progress=provider_data.on_print_progress,
  116. on_print_missing_spool_assignment=provider_data.on_print_missing_spool_assignment,
  117. on_billing_charge_failed=provider_data.on_billing_charge_failed,
  118. # Printer status events
  119. on_printer_offline=provider_data.on_printer_offline,
  120. on_printer_error=provider_data.on_printer_error,
  121. on_ai_failure_detection=provider_data.on_ai_failure_detection,
  122. on_filament_low=provider_data.on_filament_low,
  123. on_maintenance_due=provider_data.on_maintenance_due,
  124. # AMS environmental alarms (regular AMS)
  125. on_ams_humidity_high=provider_data.on_ams_humidity_high,
  126. on_ams_temperature_high=provider_data.on_ams_temperature_high,
  127. on_ams_drying_suspended=provider_data.on_ams_drying_suspended,
  128. # AMS-HT environmental alarms
  129. on_ams_ht_humidity_high=provider_data.on_ams_ht_humidity_high,
  130. on_ams_ht_temperature_high=provider_data.on_ams_ht_temperature_high,
  131. # Build plate detection
  132. on_plate_not_empty=provider_data.on_plate_not_empty,
  133. on_plate_clear_required=provider_data.on_plate_clear_required,
  134. # Bed cooled
  135. on_bed_cooled=provider_data.on_bed_cooled,
  136. # First layer complete
  137. on_first_layer_complete=provider_data.on_first_layer_complete,
  138. # Print queue events
  139. on_queue_job_added=provider_data.on_queue_job_added,
  140. on_queue_job_assigned=provider_data.on_queue_job_assigned,
  141. on_queue_job_started=provider_data.on_queue_job_started,
  142. on_queue_job_waiting=provider_data.on_queue_job_waiting,
  143. on_queue_job_skipped=provider_data.on_queue_job_skipped,
  144. on_queue_job_failed=provider_data.on_queue_job_failed,
  145. on_queue_completed=provider_data.on_queue_completed,
  146. # Quiet hours
  147. quiet_hours_enabled=provider_data.quiet_hours_enabled,
  148. quiet_hours_start=provider_data.quiet_hours_start,
  149. quiet_hours_end=provider_data.quiet_hours_end,
  150. # Daily digest
  151. daily_digest_enabled=provider_data.daily_digest_enabled,
  152. daily_digest_time=provider_data.daily_digest_time,
  153. # Printer filter
  154. printer_id=provider_data.printer_id,
  155. )
  156. db.add(provider)
  157. await db.commit()
  158. await db.refresh(provider)
  159. logger.info("Created notification provider: %s (%s)", provider.name, provider.provider_type)
  160. return _provider_to_dict(provider)
  161. # ============================================================================
  162. # Static Path Routes (must come BEFORE parameterized routes)
  163. # ============================================================================
  164. @router.post("/test-config", response_model=NotificationTestResponse)
  165. async def test_notification_config(
  166. test_request: NotificationTestRequest,
  167. db: AsyncSession = Depends(get_db),
  168. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_CREATE),
  169. ):
  170. """Test notification configuration before saving."""
  171. success, message = await notification_service.send_test_notification(
  172. test_request.provider_type.value, test_request.config, db
  173. )
  174. return NotificationTestResponse(success=success, message=message)
  175. @router.post("/test-all")
  176. async def test_all_notification_providers(
  177. db: AsyncSession = Depends(get_db),
  178. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  179. ):
  180. """Send a test notification to all enabled providers."""
  181. result = await db.execute(select(NotificationProvider).where(NotificationProvider.enabled.is_(True)))
  182. providers = result.scalars().all()
  183. if not providers:
  184. return {"tested": 0, "success": 0, "failed": 0, "results": []}
  185. results = []
  186. success_count = 0
  187. failed_count = 0
  188. for provider in providers:
  189. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  190. success, message = await notification_service.send_test_notification(provider.provider_type, config, db)
  191. # Update provider status
  192. if success:
  193. provider.last_success = datetime.now(timezone.utc)
  194. success_count += 1
  195. else:
  196. provider.last_error = message
  197. provider.last_error_at = datetime.now(timezone.utc)
  198. failed_count += 1
  199. results.append(
  200. {
  201. "provider_id": provider.id,
  202. "provider_name": provider.name,
  203. "provider_type": provider.provider_type,
  204. "success": success,
  205. "message": message,
  206. }
  207. )
  208. await db.commit()
  209. return {
  210. "tested": len(providers),
  211. "success": success_count,
  212. "failed": failed_count,
  213. "results": results,
  214. }
  215. # ============================================================================
  216. # Notification Log Routes (must come BEFORE /{provider_id} routes)
  217. # ============================================================================
  218. @router.get("/logs", response_model=list[NotificationLogResponse])
  219. async def get_notification_logs(
  220. limit: int = Query(default=100, ge=1, le=500),
  221. offset: int = Query(default=0, ge=0),
  222. provider_id: int | None = Query(default=None),
  223. event_type: str | None = Query(default=None),
  224. success: bool | None = Query(default=None),
  225. days: int | None = Query(default=7, ge=1, le=90, description="Filter logs from the last N days"),
  226. db: AsyncSession = Depends(get_db),
  227. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  228. ):
  229. """Get notification logs with optional filters."""
  230. query = select(NotificationLog).order_by(desc(NotificationLog.created_at))
  231. # Apply filters
  232. if provider_id is not None:
  233. query = query.where(NotificationLog.provider_id == provider_id)
  234. if event_type is not None:
  235. query = query.where(NotificationLog.event_type == event_type)
  236. if success is not None:
  237. query = query.where(NotificationLog.success == success)
  238. if days is not None:
  239. cutoff = datetime.now(timezone.utc) - timedelta(days=days)
  240. query = query.where(NotificationLog.created_at >= cutoff)
  241. query = query.offset(offset).limit(limit)
  242. result = await db.execute(query)
  243. logs = result.scalars().all()
  244. # Get provider info for each log
  245. response = []
  246. providers_cache: dict[int, NotificationProvider | None] = {}
  247. for log in logs:
  248. if log.provider_id not in providers_cache:
  249. provider_result = await db.execute(
  250. select(NotificationProvider).where(NotificationProvider.id == log.provider_id)
  251. )
  252. providers_cache[log.provider_id] = provider_result.scalar_one_or_none()
  253. provider = providers_cache[log.provider_id]
  254. response.append(
  255. NotificationLogResponse(
  256. id=log.id,
  257. provider_id=log.provider_id,
  258. provider_name=provider.name if provider else None,
  259. provider_type=provider.provider_type if provider else None,
  260. event_type=log.event_type,
  261. title=log.title,
  262. message=log.message,
  263. success=log.success,
  264. error_message=log.error_message,
  265. printer_id=log.printer_id,
  266. printer_name=log.printer_name,
  267. created_at=log.created_at,
  268. )
  269. )
  270. return response
  271. @router.get("/logs/stats", response_model=NotificationLogStats)
  272. async def get_notification_log_stats(
  273. days: int = Query(default=7, ge=1, le=90, description="Statistics for the last N days"),
  274. db: AsyncSession = Depends(get_db),
  275. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  276. ):
  277. """Get notification log statistics."""
  278. cutoff = datetime.now(timezone.utc) - timedelta(days=days)
  279. # Total counts
  280. total_result = await db.execute(select(func.count(NotificationLog.id)).where(NotificationLog.created_at >= cutoff))
  281. total = total_result.scalar() or 0
  282. success_result = await db.execute(
  283. select(func.count(NotificationLog.id)).where(
  284. NotificationLog.created_at >= cutoff, NotificationLog.success.is_(True)
  285. )
  286. )
  287. success_count = success_result.scalar() or 0
  288. # By event type
  289. event_result = await db.execute(
  290. select(NotificationLog.event_type, func.count(NotificationLog.id))
  291. .where(NotificationLog.created_at >= cutoff)
  292. .group_by(NotificationLog.event_type)
  293. )
  294. by_event_type = {row[0]: row[1] for row in event_result.fetchall()}
  295. # By provider (need to join to get name)
  296. provider_result = await db.execute(
  297. select(NotificationProvider.name, func.count(NotificationLog.id))
  298. .join(NotificationProvider, NotificationLog.provider_id == NotificationProvider.id)
  299. .where(NotificationLog.created_at >= cutoff)
  300. .group_by(NotificationProvider.name)
  301. )
  302. by_provider = {row[0]: row[1] for row in provider_result.fetchall()}
  303. return NotificationLogStats(
  304. total=total,
  305. success_count=success_count,
  306. failure_count=total - success_count,
  307. by_event_type=by_event_type,
  308. by_provider=by_provider,
  309. )
  310. @router.delete("/logs")
  311. async def clear_notification_logs(
  312. older_than_days: int = Query(default=30, ge=1, description="Delete logs older than N days"),
  313. db: AsyncSession = Depends(get_db),
  314. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
  315. ):
  316. """Clear old notification logs."""
  317. cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
  318. result = await db.execute(delete(NotificationLog).where(NotificationLog.created_at < cutoff))
  319. await db.commit()
  320. deleted_count = result.rowcount
  321. logger.info("Deleted %s notification logs older than %s days", deleted_count, older_than_days)
  322. return {"deleted": deleted_count, "message": f"Deleted {deleted_count} logs older than {older_than_days} days"}
  323. # ============================================================================
  324. # Provider Instance Routes (parameterized - must come LAST)
  325. # ============================================================================
  326. @router.get("/{provider_id}", response_model=NotificationProviderResponse)
  327. async def get_notification_provider(
  328. provider_id: int,
  329. db: AsyncSession = Depends(get_db),
  330. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_READ),
  331. ):
  332. """Get a specific notification provider."""
  333. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  334. provider = result.scalar_one_or_none()
  335. if not provider:
  336. raise HTTPException(status_code=404, detail="Notification provider not found")
  337. return _provider_to_dict(provider)
  338. @router.patch("/{provider_id}", response_model=NotificationProviderResponse)
  339. async def update_notification_provider(
  340. provider_id: int,
  341. update_data: NotificationProviderUpdate,
  342. db: AsyncSession = Depends(get_db),
  343. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  344. ):
  345. """Update a notification provider."""
  346. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  347. provider = result.scalar_one_or_none()
  348. if not provider:
  349. raise HTTPException(status_code=404, detail="Notification provider not found")
  350. # Update only provided fields
  351. update_dict = update_data.model_dump(exclude_unset=True)
  352. for key, value in update_dict.items():
  353. if key == "config" and value is not None:
  354. setattr(provider, key, json.dumps(value))
  355. elif key == "provider_type" and value is not None:
  356. setattr(provider, key, value.value)
  357. else:
  358. setattr(provider, key, value)
  359. await db.commit()
  360. await db.refresh(provider)
  361. logger.info("Updated notification provider: %s", provider.name)
  362. return _provider_to_dict(provider)
  363. @router.delete("/{provider_id}")
  364. async def delete_notification_provider(
  365. provider_id: int,
  366. db: AsyncSession = Depends(get_db),
  367. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_DELETE),
  368. ):
  369. """Delete a notification provider."""
  370. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  371. provider = result.scalar_one_or_none()
  372. if not provider:
  373. raise HTTPException(status_code=404, detail="Notification provider not found")
  374. name = provider.name
  375. await db.delete(provider)
  376. await db.commit()
  377. logger.info("Deleted notification provider: %s", name)
  378. return {"message": f"Notification provider '{name}' deleted"}
  379. @router.post("/{provider_id}/test", response_model=NotificationTestResponse)
  380. async def test_notification_provider(
  381. provider_id: int,
  382. db: AsyncSession = Depends(get_db),
  383. _: User | None = RequirePermissionIfAuthEnabled(Permission.NOTIFICATIONS_UPDATE),
  384. ):
  385. """Send a test notification using an existing provider."""
  386. result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
  387. provider = result.scalar_one_or_none()
  388. if not provider:
  389. raise HTTPException(status_code=404, detail="Notification provider not found")
  390. config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
  391. success, message = await notification_service.send_test_notification(provider.provider_type, config, db)
  392. # Update provider status
  393. if success:
  394. provider.last_success = datetime.now(timezone.utc)
  395. else:
  396. provider.last_error = message
  397. provider.last_error_at = datetime.now(timezone.utc)
  398. await db.commit()
  399. return NotificationTestResponse(success=success, message=message)