notification.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. """Pydantic schemas for notification providers."""
  2. from datetime import datetime
  3. from typing import Any
  4. from pydantic import BaseModel, Field, field_validator, model_validator
  5. from backend.app.core.compat import StrEnum
  6. class ProviderType(StrEnum):
  7. """Supported notification provider types."""
  8. CALLMEBOT = "callmebot"
  9. NTFY = "ntfy"
  10. PUSHOVER = "pushover"
  11. TELEGRAM = "telegram"
  12. EMAIL = "email"
  13. DISCORD = "discord"
  14. WEBHOOK = "webhook"
  15. HOMEASSISTANT = "homeassistant"
  16. BARK = "bark"
  17. class NotificationProviderBase(BaseModel):
  18. """Base schema for notification providers."""
  19. name: str = Field(..., min_length=1, max_length=100, description="User-defined name")
  20. provider_type: ProviderType = Field(..., description="Type of notification provider")
  21. enabled: bool = Field(default=True, description="Whether notifications are enabled")
  22. config: dict[str, Any] = Field(..., description="Provider-specific configuration")
  23. # Event triggers - print lifecycle
  24. on_print_start: bool = Field(default=False, description="Notify on print start")
  25. on_print_complete: bool = Field(default=True, description="Notify on print complete")
  26. on_print_failed: bool = Field(default=True, description="Notify on print failed")
  27. on_print_stopped: bool = Field(default=True, description="Notify when print is stopped/cancelled")
  28. on_print_progress: bool = Field(default=False, description="Notify at 25%, 50%, 75% progress")
  29. on_print_missing_spool_assignment: bool = Field(
  30. default=False,
  31. description="Notify when a print starts with required trays missing spool assignments",
  32. )
  33. on_billing_charge_failed: bool = Field(default=True, description="Notify when a print charge cannot be recorded")
  34. # Event triggers - printer status
  35. on_printer_offline: bool = Field(default=False, description="Notify when printer goes offline")
  36. on_printer_error: bool = Field(default=False, description="Notify on printer errors (AMS, etc.)")
  37. on_ai_failure_detection: bool = Field(
  38. default=False,
  39. description="Notify when Obico AI detects a possible print failure (spaghetti)",
  40. )
  41. on_filament_low: bool = Field(default=False, description="Notify when filament is running low")
  42. on_maintenance_due: bool = Field(default=False, description="Notify when maintenance is due")
  43. # Event triggers - AMS environmental alarms (regular AMS)
  44. on_ams_humidity_high: bool = Field(default=False, description="Notify when AMS humidity exceeds threshold")
  45. on_ams_temperature_high: bool = Field(default=False, description="Notify when AMS temperature exceeds threshold")
  46. on_ams_drying_suspended: bool = Field(
  47. default=True, description="Notify when automatic drying gives up on an AMS unit"
  48. )
  49. # Event triggers - AMS-HT environmental alarms
  50. on_ams_ht_humidity_high: bool = Field(default=False, description="Notify when AMS-HT humidity exceeds threshold")
  51. on_ams_ht_temperature_high: bool = Field(
  52. default=False, description="Notify when AMS-HT temperature exceeds threshold"
  53. )
  54. # Event triggers - Home Assistant sensors bound to a printer (#1148)
  55. on_ha_sensor_alert: bool = Field(
  56. default=False, description="Notify when a bound Home Assistant sensor enters its alert state"
  57. )
  58. # Event triggers - Home Assistant sensors bound to a storage location (#2824)
  59. on_location_ha_sensor_alert: bool = Field(
  60. default=False,
  61. description="Notify when a Home Assistant sensor bound to a storage location enters its alert state",
  62. )
  63. # Event triggers - Build plate detection
  64. on_plate_not_empty: bool = Field(default=True, description="Notify when objects detected on plate before print")
  65. on_plate_clear_required: bool = Field(
  66. default=False, description="Notify when a finished print is waiting for plate-clear confirmation"
  67. )
  68. # Event triggers - Post-print outcome confirmation (#1898)
  69. on_print_confirm_request: bool = Field(
  70. default=True,
  71. description="Notify with one-tap verdict links when a print that opted in asks for its outcome",
  72. )
  73. # Event triggers - Bed cooled
  74. on_bed_cooled: bool = Field(default=False, description="Notify when bed cools after print")
  75. # Event triggers - First layer complete
  76. on_first_layer_complete: bool = Field(default=False, description="Notify when first layer completes")
  77. # Event triggers - Inventory stock alerts
  78. # Missing from this schema until now, so every payload naming them was
  79. # dropped silently: the UI's toggles round-tripped as 200 OK and the row
  80. # never changed, and _provider_to_dict never returned them either, so they
  81. # always read back off. The columns and the sending code have existed since
  82. # the inventory forecast landed.
  83. on_stock_reorder_alert: bool = Field(
  84. default=False, description="Notify when an inventory SKU hits its reorder point"
  85. )
  86. on_stock_break_alert: bool = Field(
  87. default=False, description="Notify when stock will run out before replenishment arrives"
  88. )
  89. # Event triggers - Print queue
  90. on_queue_job_added: bool = Field(default=False, description="Notify when job is added to queue")
  91. on_queue_job_assigned: bool = Field(default=False, description="Notify when model-based job is assigned to printer")
  92. on_queue_job_started: bool = Field(default=False, description="Notify when queue job starts printing")
  93. on_queue_job_waiting: bool = Field(default=True, description="Notify when job is waiting for filament or printer")
  94. on_queue_job_skipped: bool = Field(default=True, description="Notify when job is skipped")
  95. on_queue_job_failed: bool = Field(default=True, description="Notify when job fails to start")
  96. on_queue_completed: bool = Field(default=False, description="Notify when all queue jobs finish")
  97. # Quiet hours
  98. quiet_hours_enabled: bool = Field(default=False, description="Enable quiet hours")
  99. quiet_hours_start: str | None = Field(default=None, description="Start time in HH:MM format")
  100. quiet_hours_end: str | None = Field(default=None, description="End time in HH:MM format")
  101. # Daily digest
  102. daily_digest_enabled: bool = Field(default=False, description="Batch notifications into daily digest")
  103. daily_digest_time: str | None = Field(default=None, description="Time to send digest in HH:MM format")
  104. # Printer filter
  105. printer_id: int | None = Field(default=None, description="Specific printer ID or null for all")
  106. @field_validator("quiet_hours_start", "quiet_hours_end", "daily_digest_time")
  107. @classmethod
  108. def validate_time_format(cls, v: str | None) -> str | None:
  109. if v is None:
  110. return v
  111. try:
  112. parts = v.split(":")
  113. if len(parts) != 2:
  114. raise ValueError("Invalid time format")
  115. hour, minute = int(parts[0]), int(parts[1])
  116. if not (0 <= hour <= 23 and 0 <= minute <= 59):
  117. raise ValueError("Invalid time range")
  118. return f"{hour:02d}:{minute:02d}"
  119. except (ValueError, TypeError):
  120. raise ValueError("Time must be in HH:MM format (e.g., 22:00)")
  121. class NotificationProviderCreate(NotificationProviderBase):
  122. """Schema for creating a notification provider."""
  123. pass
  124. class NotificationProviderUpdate(BaseModel):
  125. """Schema for updating a notification provider (all fields optional)."""
  126. name: str | None = Field(default=None, min_length=1, max_length=100)
  127. provider_type: ProviderType | None = None
  128. enabled: bool | None = None
  129. config: dict[str, Any] | None = None
  130. # Event triggers - print lifecycle
  131. on_print_start: bool | None = None
  132. on_print_complete: bool | None = None
  133. on_print_failed: bool | None = None
  134. on_print_stopped: bool | None = None
  135. on_print_progress: bool | None = None
  136. on_print_missing_spool_assignment: bool | None = None
  137. on_billing_charge_failed: bool | None = None
  138. # Event triggers - printer status
  139. on_printer_offline: bool | None = None
  140. on_printer_error: bool | None = None
  141. on_ai_failure_detection: bool | None = None
  142. on_filament_low: bool | None = None
  143. on_maintenance_due: bool | None = None
  144. # Event triggers - AMS environmental alarms (regular AMS)
  145. on_ams_humidity_high: bool | None = None
  146. on_ams_temperature_high: bool | None = None
  147. on_ams_drying_suspended: bool | None = None
  148. # Event triggers - AMS-HT environmental alarms
  149. on_ams_ht_humidity_high: bool | None = None
  150. on_ams_ht_temperature_high: bool | None = None
  151. # Event triggers - Home Assistant sensors bound to a printer (#1148)
  152. on_ha_sensor_alert: bool | None = None
  153. # Event triggers - Home Assistant sensors bound to a storage location (#2824)
  154. on_location_ha_sensor_alert: bool | None = None
  155. # Event triggers - Build plate detection
  156. on_plate_not_empty: bool | None = None
  157. on_plate_clear_required: bool | None = None
  158. # Event triggers - Post-print outcome confirmation (#1898)
  159. on_print_confirm_request: bool | None = None
  160. # Event triggers - Bed cooled
  161. on_bed_cooled: bool | None = None
  162. # Event triggers - First layer complete
  163. on_first_layer_complete: bool | None = None
  164. # Event triggers - Inventory stock alerts
  165. on_stock_reorder_alert: bool | None = None
  166. on_stock_break_alert: bool | None = None
  167. # Event triggers - Print queue
  168. on_queue_job_added: bool | None = None
  169. on_queue_job_assigned: bool | None = None
  170. on_queue_job_started: bool | None = None
  171. on_queue_job_waiting: bool | None = None
  172. on_queue_job_skipped: bool | None = None
  173. on_queue_job_failed: bool | None = None
  174. on_queue_completed: bool | None = None
  175. # Quiet hours
  176. quiet_hours_enabled: bool | None = None
  177. quiet_hours_start: str | None = None
  178. quiet_hours_end: str | None = None
  179. # Daily digest
  180. daily_digest_enabled: bool | None = None
  181. daily_digest_time: str | None = None
  182. # Printer filter
  183. printer_id: int | None = None
  184. class NotificationProviderResponse(NotificationProviderBase):
  185. """Schema for notification provider API responses."""
  186. @model_validator(mode="before")
  187. @classmethod
  188. def _null_event_flags_read_as_off(cls, data: Any) -> Any:
  189. """Read a NULL event flag as off instead of failing the whole response.
  190. Every on_* column on notification_providers is nullable with no server
  191. default -- the values come from the ORM at INSERT time. A row created
  192. before a flag's column existed keeps NULL there forever unless a
  193. migration backfills it, and one that did not (the column was created by
  194. Base.metadata before run_migrations, so the ALTER ... DEFAULT false was
  195. swallowed as a duplicate) leaves NULLs behind on a live install.
  196. Those NULLs are harmless until the flag is declared on this schema: the
  197. Response inherits the write model, so `bool` is then required on the way
  198. out, pydantic rejects None, and every provider row fails at once -- the
  199. list route 500s and the UI renders an empty list, which reads to the user
  200. as "my providers are gone". That is exactly what shipped in #2827.
  201. Off is not a guess: _get_providers_for_event selects on `.is_(True)`, so
  202. the sender already skips a NULL flag. This makes the read agree with the
  203. behaviour the row already has, rather than with the field's declared
  204. default -- some of which are True, and none of which should switch a
  205. notification on as a side effect of repairing a legacy row.
  206. Writes are untouched: Create and Update inherit from the base, not here,
  207. so a payload sending null for a flag is still a 422.
  208. """
  209. # Every route returns _provider_to_dict(); anything else (an ORM object
  210. # via from_attributes) is passed through for pydantic to handle.
  211. if not isinstance(data, dict):
  212. return data
  213. flags = [name for name, f in cls.model_fields.items() if f.annotation is bool]
  214. if any(data.get(name, False) is None for name in flags):
  215. data = {**data, **{name: False for name in flags if data.get(name, False) is None}}
  216. return data
  217. id: int
  218. last_success: datetime | None = None
  219. last_error: str | None = None
  220. last_error_at: datetime | None = None
  221. created_at: datetime
  222. updated_at: datetime
  223. class Config:
  224. from_attributes = True
  225. class NotificationTestRequest(BaseModel):
  226. """Schema for testing notification configuration."""
  227. provider_type: ProviderType
  228. config: dict[str, Any]
  229. class NotificationTestResponse(BaseModel):
  230. """Schema for test notification response."""
  231. success: bool
  232. message: str
  233. # Provider-specific config schemas for documentation/validation reference
  234. class CallMeBotConfig(BaseModel):
  235. """CallMeBot/WhatsApp configuration."""
  236. phone: str = Field(..., description="Phone number with country code (e.g., +1234567890)")
  237. apikey: str = Field(..., description="API key from CallMeBot")
  238. class NtfyConfig(BaseModel):
  239. """ntfy configuration."""
  240. server: str = Field(default="https://ntfy.sh", description="ntfy server URL")
  241. topic: str = Field(..., description="Topic name to publish to")
  242. auth_token: str | None = Field(default=None, description="Optional authentication token")
  243. event_priorities: dict[str, int] | None = Field(
  244. default=None,
  245. description=(
  246. "Per-event priority override. Keys are event names, either the provider's "
  247. "toggle column ('on_print_failed', what the UI writes) or the bare event "
  248. "name ('print_failed'); both are accepted. Values are ntfy priorities 1-5 "
  249. "(1=min, 2=low, 3=default, 4=high, 5=urgent). Events without an entry use "
  250. "ntfy's server-side default."
  251. ),
  252. )
  253. class PushoverConfig(BaseModel):
  254. """Pushover configuration."""
  255. user_key: str = Field(..., description="Your Pushover user key")
  256. app_token: str = Field(..., description="Your Pushover application token")
  257. priority: int = Field(default=0, ge=-2, le=2, description="Message priority (-2 to 2)")
  258. # Emergency priority (2) only: how often to re-alert and when to stop.
  259. # Pushover requires retry >= 30s and expire <= 10800s (3h).
  260. retry: int = Field(default=60, ge=30, le=10800, description="Emergency re-alert interval in seconds (priority 2)")
  261. expire: int = Field(default=3600, ge=30, le=10800, description="Emergency alert expiry in seconds (priority 2)")
  262. class TelegramConfig(BaseModel):
  263. """Telegram bot configuration."""
  264. bot_token: str = Field(..., description="Bot token from @BotFather")
  265. chat_id: str = Field(..., description="Chat ID to send messages to")
  266. class EmailConfig(BaseModel):
  267. """Email/SMTP configuration."""
  268. smtp_server: str = Field(..., description="SMTP server hostname")
  269. smtp_port: int = Field(default=587, description="SMTP port (587 for TLS, 465 for SSL)")
  270. username: str = Field(..., description="SMTP username/email")
  271. password: str = Field(..., description="SMTP password or app password")
  272. from_email: str = Field(..., description="From email address")
  273. to_email: str = Field(..., description="Recipient email address")
  274. use_tls: bool = Field(default=True, description="Use TLS encryption")
  275. # Notification Log schemas
  276. class NotificationLogResponse(BaseModel):
  277. """Schema for notification log API responses."""
  278. id: int
  279. provider_id: int
  280. provider_name: str | None = None
  281. provider_type: str | None = None
  282. event_type: str
  283. title: str
  284. message: str
  285. success: bool
  286. error_message: str | None = None
  287. printer_id: int | None = None
  288. printer_name: str | None = None
  289. created_at: datetime
  290. class Config:
  291. from_attributes = True
  292. class NotificationLogStats(BaseModel):
  293. """Statistics for notification logs."""
  294. total: int
  295. success_count: int
  296. failure_count: int
  297. by_event_type: dict[str, int]
  298. by_provider: dict[str, int]