notification.py 11 KB

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