notification.py 16 KB

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