notification.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. """Notification provider and log models for push notifications."""
  2. from datetime import datetime
  3. from typing import TYPE_CHECKING
  4. from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
  5. from sqlalchemy.orm import Mapped, mapped_column, relationship
  6. from backend.app.core.database import Base
  7. if TYPE_CHECKING:
  8. from backend.app.models.printer import Printer
  9. class NotificationDigestQueue(Base):
  10. """Model for queuing notifications to be sent in daily digest."""
  11. __tablename__ = "notification_digest_queue"
  12. id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
  13. provider_id: Mapped[int] = mapped_column(
  14. Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
  15. )
  16. event_type: Mapped[str] = mapped_column(String(50), nullable=False) # print_start, print_complete, etc.
  17. title: Mapped[str] = mapped_column(String(255), nullable=False)
  18. message: Mapped[str] = mapped_column(Text, nullable=False)
  19. printer_id: Mapped[int | None] = mapped_column(
  20. Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
  21. )
  22. printer_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
  23. created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
  24. # Relationships
  25. provider: Mapped["NotificationProvider"] = relationship(back_populates="digest_queue")
  26. class NotificationLog(Base):
  27. """Model for logging sent notifications."""
  28. __tablename__ = "notification_logs"
  29. id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
  30. provider_id: Mapped[int] = mapped_column(
  31. Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
  32. )
  33. event_type: Mapped[str] = mapped_column(String(50), nullable=False) # print_start, print_complete, etc.
  34. title: Mapped[str] = mapped_column(String(255), nullable=False)
  35. message: Mapped[str] = mapped_column(Text, nullable=False)
  36. success: Mapped[bool | None] = mapped_column(Boolean, default=True)
  37. error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
  38. printer_id: Mapped[int | None] = mapped_column(
  39. Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
  40. )
  41. printer_name: Mapped[str | None] = mapped_column(
  42. String(100), nullable=True
  43. ) # Store name in case printer is deleted
  44. created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
  45. # Relationships
  46. provider: Mapped["NotificationProvider"] = relationship(back_populates="logs")
  47. class NotificationProvider(Base):
  48. """Model for notification providers (WhatsApp, ntfy, Pushover, etc.)."""
  49. __tablename__ = "notification_providers"
  50. id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
  51. name: Mapped[str] = mapped_column(String(100), nullable=False) # User-defined name
  52. provider_type: Mapped[str] = mapped_column(String(50), nullable=False) # callmebot, ntfy, pushover, telegram, email
  53. enabled: Mapped[bool | None] = mapped_column(Boolean, default=True)
  54. # Provider-specific configuration stored as JSON string
  55. config: Mapped[str] = mapped_column(Text, nullable=False)
  56. # Attach a camera snapshot when one was captured for the event. Defaults
  57. # True so ntfy/Pushover/Telegram/Discord keep behaving as they always
  58. # did; this is the opt-out for whoever doesn't want it.
  59. attach_photo: Mapped[bool | None] = mapped_column(Boolean, default=True)
  60. # Event triggers - print lifecycle
  61. on_print_start: Mapped[bool | None] = mapped_column(Boolean, default=False)
  62. on_print_complete: Mapped[bool | None] = mapped_column(Boolean, default=True)
  63. on_print_failed: Mapped[bool | None] = mapped_column(Boolean, default=True)
  64. on_print_stopped: Mapped[bool | None] = mapped_column(Boolean, default=True) # User cancelled/stopped print
  65. on_print_progress: Mapped[bool | None] = mapped_column(Boolean, default=False) # 25%, 50%, 75% milestones
  66. on_print_missing_spool_assignment: Mapped[bool | None] = mapped_column(
  67. Boolean, default=False
  68. ) # Print started with unassigned required tray(s)
  69. on_billing_charge_failed: Mapped[bool | None] = mapped_column(
  70. Boolean, default=True
  71. ) # A completed/stopped print could not be charged
  72. # Event triggers - printer status
  73. on_printer_offline: Mapped[bool | None] = mapped_column(Boolean, default=False)
  74. on_printer_error: Mapped[bool | None] = mapped_column(Boolean, default=False) # AMS issues, etc.
  75. on_ai_failure_detection: Mapped[bool | None] = mapped_column(
  76. Boolean, default=False
  77. ) # Obico spaghetti / failure detection (#1794)
  78. on_filament_low: Mapped[bool | None] = mapped_column(Boolean, default=False)
  79. on_maintenance_due: Mapped[bool | None] = mapped_column(Boolean, default=False) # Maintenance reminder
  80. # Event triggers - AMS environmental alarms (regular AMS with 4 slots)
  81. on_ams_humidity_high: Mapped[bool | None] = mapped_column(Boolean, default=False) # AMS humidity above threshold
  82. on_ams_temperature_high: Mapped[bool | None] = mapped_column(
  83. Boolean, default=False
  84. ) # AMS temperature above threshold
  85. # Auto-drying gave up on a unit (#2770). Defaults True: it reports that
  86. # Bambuddy has stopped acting, which nothing else in the UI would say.
  87. on_ams_drying_suspended: Mapped[bool | None] = mapped_column(Boolean, default=True)
  88. # Event triggers - AMS-HT environmental alarms (single slot heated AMS)
  89. on_ams_ht_humidity_high: Mapped[bool | None] = mapped_column(
  90. Boolean, default=False
  91. ) # AMS-HT humidity above threshold
  92. on_ams_ht_temperature_high: Mapped[bool | None] = mapped_column(
  93. Boolean, default=False
  94. ) # AMS-HT temperature above threshold
  95. # Event triggers - Home Assistant sensors bound to a printer (#1148)
  96. on_ha_sensor_alert: Mapped[bool | None] = mapped_column(
  97. Boolean, default=False
  98. ) # Bound HA sensor entered its alert state
  99. # Event triggers - Home Assistant sensors bound to a storage location (#2824)
  100. # Its own column rather than reusing on_ha_sensor_alert above: that one can
  101. # be scoped to a single printer, and a location alert has no printer to
  102. # scope by, so sharing it would leak drybox alerts to a provider narrowed
  103. # to one printer's sensors.
  104. on_location_ha_sensor_alert: Mapped[bool | None] = mapped_column(Boolean, default=False)
  105. # Event triggers - Build plate detection
  106. on_plate_not_empty: Mapped[bool | None] = mapped_column(
  107. Boolean, default=True
  108. ) # Objects detected on plate before print
  109. # Off by default: fires after every print, alongside the print-complete alert (#2525)
  110. on_plate_clear_required: Mapped[bool | None] = mapped_column(
  111. Boolean, default=False
  112. ) # Print ended, queue gated until plate is confirmed clear
  113. # Print asked for an outcome verdict (#1898). Defaults ON: it only ever
  114. # fires for prints where the user opted in per-job, so the provider-level
  115. # toggle exists to silence a channel, not to enable the feature.
  116. on_print_confirm_request: Mapped[bool | None] = mapped_column(Boolean, default=True)
  117. # How a Telegram provider collects that verdict (#3046): inline URL
  118. # buttons ("buttons", the original behaviour), a thumbs-up/down reaction
  119. # on the message ("reactions"), or both. Reactions need no inbound
  120. # connectivity — the poller fetches them — so they work without an
  121. # external_url. Ignored for every other provider type.
  122. telegram_verdict_mode: Mapped[str | None] = mapped_column(String(16), default="buttons")
  123. # Event triggers - Bed cooled after print
  124. on_bed_cooled: Mapped[bool | None] = mapped_column(Boolean, default=False) # Bed cooled below threshold after print
  125. on_first_layer_complete: Mapped[bool | None] = mapped_column(
  126. Boolean, default=False
  127. ) # First layer finished printing
  128. # Messages another application sends through Bambuddy (POST /notifications/app-message),
  129. # e.g. Bambuddy Orders' "an order needs you". Off by default: nothing new arrives on upgrade.
  130. on_app_message: Mapped[bool | None] = mapped_column(Boolean, default=False)
  131. # Event triggers - Inventory stock alerts
  132. on_stock_reorder_alert: Mapped[bool | None] = mapped_column(Boolean, default=False) # SKU hits reorder point
  133. on_stock_break_alert: Mapped[bool | None] = mapped_column(
  134. Boolean, default=False
  135. ) # Stock will run out before replenishment
  136. # Event triggers - Print queue
  137. on_queue_job_added: Mapped[bool | None] = mapped_column(Boolean, default=False) # Job added to queue
  138. on_queue_job_assigned: Mapped[bool | None] = mapped_column(
  139. Boolean, default=False
  140. ) # Model-based job assigned to printer
  141. on_queue_job_started: Mapped[bool | None] = mapped_column(Boolean, default=False) # Queue job started printing
  142. on_queue_job_waiting: Mapped[bool | None] = mapped_column(
  143. Boolean, default=True
  144. ) # Job waiting for filament or printer
  145. on_queue_job_skipped: Mapped[bool | None] = mapped_column(
  146. Boolean, default=True
  147. ) # Job skipped (previous print failed)
  148. on_queue_job_failed: Mapped[bool | None] = mapped_column(Boolean, default=True) # Job failed to start
  149. on_queue_completed: Mapped[bool | None] = mapped_column(Boolean, default=False) # All pending jobs finished
  150. # Quiet hours (do not disturb)
  151. quiet_hours_enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
  152. quiet_hours_start: Mapped[str | None] = mapped_column(String(5), nullable=True) # HH:MM format, e.g., "22:00"
  153. quiet_hours_end: Mapped[str | None] = mapped_column(String(5), nullable=True) # HH:MM format, e.g., "07:00"
  154. # Daily digest (batch notifications into a single daily summary)
  155. daily_digest_enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
  156. daily_digest_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # HH:MM format, e.g., "08:00"
  157. # Optional: Link to specific printer (NULL = all printers)
  158. printer_id: Mapped[int | None] = mapped_column(
  159. Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
  160. )
  161. # Status tracking
  162. last_success: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  163. last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
  164. last_error_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
  165. # Timestamps
  166. created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow)
  167. updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
  168. # Relationships
  169. printer: Mapped["Printer | None"] = relationship(back_populates="notification_providers")
  170. logs: Mapped[list["NotificationLog"]] = relationship(back_populates="provider", cascade="all, delete-orphan")
  171. digest_queue: Mapped[list["NotificationDigestQueue"]] = relationship(
  172. back_populates="provider", cascade="all, delete-orphan"
  173. )
  174. # ORM-level cascade like the two above: SQLite ships with foreign keys
  175. # off, so the column's ON DELETE CASCADE alone would leave rows behind.
  176. pending_verdicts: Mapped[list["TelegramPendingVerdict"]] = relationship(
  177. back_populates="provider", cascade="all, delete-orphan"
  178. )
  179. class TelegramPendingVerdict(Base):
  180. """A Telegram outcome prompt still waiting for a thumbs-up/down reaction (#3046).
  181. One row per delivered ``print_confirm_request`` message on a provider in
  182. "reactions" or "both" mode. The reaction poller matches incoming
  183. ``message_reaction`` updates against (provider, chat, message) and deletes
  184. the row once a verdict landed; rows older than a week are pruned.
  185. """
  186. __tablename__ = "telegram_pending_verdicts"
  187. id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
  188. provider_id: Mapped[int] = mapped_column(
  189. Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
  190. )
  191. chat_id: Mapped[str] = mapped_column(String(64), nullable=False)
  192. message_id: Mapped[int] = mapped_column(Integer, nullable=False)
  193. archive_id: Mapped[int] = mapped_column(
  194. Integer, ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=False
  195. )
  196. # The archive's confirm_token when the prompt went out. A reaction counts
  197. # only while that is still the archive's live token, i.e. exactly as long
  198. # as the prompt's one-tap links would: a reprint clears the token, so an
  199. # earlier run's message can no longer answer the new run.
  200. confirm_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
  201. # sendPhoto messages carry the prompt as a caption, which is edited with a
  202. # different Bot API method than a plain text message.
  203. has_caption: Mapped[bool | None] = mapped_column(Boolean, default=False)
  204. # The Markdown we sent, so the confirmation edit can append to it — the
  205. # reaction update does not carry the message body.
  206. message_text: Mapped[str | None] = mapped_column(Text, nullable=True)
  207. created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
  208. provider: Mapped["NotificationProvider"] = relationship(back_populates="pending_verdicts")