|
|
@@ -1,29 +1,37 @@
|
|
|
"""Notification provider and log models for push notifications."""
|
|
|
|
|
|
from datetime import datetime
|
|
|
+from typing import TYPE_CHECKING
|
|
|
|
|
|
-from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text
|
|
|
-from sqlalchemy.orm import relationship
|
|
|
+from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
|
|
|
+from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
|
|
from backend.app.core.database import Base
|
|
|
|
|
|
+if TYPE_CHECKING:
|
|
|
+ from backend.app.models.printer import Printer
|
|
|
+
|
|
|
|
|
|
class NotificationDigestQueue(Base):
|
|
|
"""Model for queuing notifications to be sent in daily digest."""
|
|
|
|
|
|
__tablename__ = "notification_digest_queue"
|
|
|
|
|
|
- id = Column(Integer, primary_key=True, index=True)
|
|
|
- provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
|
|
|
- event_type = Column(String(50), nullable=False) # print_start, print_complete, etc.
|
|
|
- title = Column(String(255), nullable=False)
|
|
|
- message = Column(Text, nullable=False)
|
|
|
- printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
|
|
|
- printer_name = Column(String(100), nullable=True)
|
|
|
- created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
|
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
|
+ provider_id: Mapped[int] = mapped_column(
|
|
|
+ Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
|
|
|
+ )
|
|
|
+ event_type: Mapped[str] = mapped_column(String(50), nullable=False) # print_start, print_complete, etc.
|
|
|
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
|
+ message: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
+ printer_id: Mapped[int | None] = mapped_column(
|
|
|
+ Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
|
|
|
+ )
|
|
|
+ printer_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
|
+ created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
|
|
|
|
|
# Relationships
|
|
|
- provider = relationship("NotificationProvider", back_populates="digest_queue")
|
|
|
+ provider: Mapped["NotificationProvider"] = relationship(back_populates="digest_queue")
|
|
|
|
|
|
|
|
|
class NotificationLog(Base):
|
|
|
@@ -31,19 +39,25 @@ class NotificationLog(Base):
|
|
|
|
|
|
__tablename__ = "notification_logs"
|
|
|
|
|
|
- id = Column(Integer, primary_key=True, index=True)
|
|
|
- provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
|
|
|
- event_type = Column(String(50), nullable=False) # print_start, print_complete, etc.
|
|
|
- title = Column(String(255), nullable=False)
|
|
|
- message = Column(Text, nullable=False)
|
|
|
- success = Column(Boolean, default=True)
|
|
|
- error_message = Column(Text, nullable=True)
|
|
|
- printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
|
|
|
- printer_name = Column(String(100), nullable=True) # Store name in case printer is deleted
|
|
|
- created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
|
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
|
+ provider_id: Mapped[int] = mapped_column(
|
|
|
+ Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
|
|
|
+ )
|
|
|
+ event_type: Mapped[str] = mapped_column(String(50), nullable=False) # print_start, print_complete, etc.
|
|
|
+ title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
|
+ message: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
+ success: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
|
|
+ error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
+ printer_id: Mapped[int | None] = mapped_column(
|
|
|
+ Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
|
|
|
+ )
|
|
|
+ printer_name: Mapped[str | None] = mapped_column(
|
|
|
+ String(100), nullable=True
|
|
|
+ ) # Store name in case printer is deleted
|
|
|
+ created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
|
|
|
|
|
# Relationships
|
|
|
- provider = relationship("NotificationProvider", back_populates="logs")
|
|
|
+ provider: Mapped["NotificationProvider"] = relationship(back_populates="logs")
|
|
|
|
|
|
|
|
|
class NotificationProvider(Base):
|
|
|
@@ -51,120 +65,154 @@ class NotificationProvider(Base):
|
|
|
|
|
|
__tablename__ = "notification_providers"
|
|
|
|
|
|
- id = Column(Integer, primary_key=True, index=True)
|
|
|
- name = Column(String(100), nullable=False) # User-defined name
|
|
|
- provider_type = Column(String(50), nullable=False) # callmebot, ntfy, pushover, telegram, email
|
|
|
- enabled = Column(Boolean, default=True)
|
|
|
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
|
+ name: Mapped[str] = mapped_column(String(100), nullable=False) # User-defined name
|
|
|
+ provider_type: Mapped[str] = mapped_column(String(50), nullable=False) # callmebot, ntfy, pushover, telegram, email
|
|
|
+ enabled: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
|
|
|
|
|
# Provider-specific configuration stored as JSON string
|
|
|
- config = Column(Text, nullable=False)
|
|
|
+ config: Mapped[str] = mapped_column(Text, nullable=False)
|
|
|
|
|
|
# Attach a camera snapshot when one was captured for the event. Defaults
|
|
|
# True so ntfy/Pushover/Telegram/Discord keep behaving as they always
|
|
|
# did; this is the opt-out for whoever doesn't want it.
|
|
|
- attach_photo = Column(Boolean, default=True)
|
|
|
+ attach_photo: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
|
|
|
|
|
# Event triggers - print lifecycle
|
|
|
- on_print_start = Column(Boolean, default=False)
|
|
|
- on_print_complete = Column(Boolean, default=True)
|
|
|
- on_print_failed = Column(Boolean, default=True)
|
|
|
- on_print_stopped = Column(Boolean, default=True) # User cancelled/stopped print
|
|
|
- on_print_progress = Column(Boolean, default=False) # 25%, 50%, 75% milestones
|
|
|
- on_print_missing_spool_assignment = Column(Boolean, default=False) # Print started with unassigned required tray(s)
|
|
|
- on_billing_charge_failed = Column(Boolean, default=True) # A completed/stopped print could not be charged
|
|
|
+ on_print_start: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
|
|
+ on_print_complete: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
|
|
+ on_print_failed: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
|
|
+ on_print_stopped: Mapped[bool | None] = mapped_column(Boolean, default=True) # User cancelled/stopped print
|
|
|
+ on_print_progress: Mapped[bool | None] = mapped_column(Boolean, default=False) # 25%, 50%, 75% milestones
|
|
|
+ on_print_missing_spool_assignment: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # Print started with unassigned required tray(s)
|
|
|
+ on_billing_charge_failed: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=True
|
|
|
+ ) # A completed/stopped print could not be charged
|
|
|
|
|
|
# Event triggers - printer status
|
|
|
- on_printer_offline = Column(Boolean, default=False)
|
|
|
- on_printer_error = Column(Boolean, default=False) # AMS issues, etc.
|
|
|
- on_ai_failure_detection = Column(Boolean, default=False) # Obico spaghetti / failure detection (#1794)
|
|
|
- on_filament_low = Column(Boolean, default=False)
|
|
|
- on_maintenance_due = Column(Boolean, default=False) # Maintenance reminder
|
|
|
+ on_printer_offline: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
|
|
+ on_printer_error: Mapped[bool | None] = mapped_column(Boolean, default=False) # AMS issues, etc.
|
|
|
+ on_ai_failure_detection: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # Obico spaghetti / failure detection (#1794)
|
|
|
+ on_filament_low: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
|
|
+ on_maintenance_due: Mapped[bool | None] = mapped_column(Boolean, default=False) # Maintenance reminder
|
|
|
|
|
|
# Event triggers - AMS environmental alarms (regular AMS with 4 slots)
|
|
|
- on_ams_humidity_high = Column(Boolean, default=False) # AMS humidity above threshold
|
|
|
- on_ams_temperature_high = Column(Boolean, default=False) # AMS temperature above threshold
|
|
|
+ on_ams_humidity_high: Mapped[bool | None] = mapped_column(Boolean, default=False) # AMS humidity above threshold
|
|
|
+ on_ams_temperature_high: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # AMS temperature above threshold
|
|
|
# Auto-drying gave up on a unit (#2770). Defaults True: it reports that
|
|
|
# Bambuddy has stopped acting, which nothing else in the UI would say.
|
|
|
- on_ams_drying_suspended = Column(Boolean, default=True)
|
|
|
+ on_ams_drying_suspended: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
|
|
|
|
|
# Event triggers - AMS-HT environmental alarms (single slot heated AMS)
|
|
|
- on_ams_ht_humidity_high = Column(Boolean, default=False) # AMS-HT humidity above threshold
|
|
|
- on_ams_ht_temperature_high = Column(Boolean, default=False) # AMS-HT temperature above threshold
|
|
|
+ on_ams_ht_humidity_high: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # AMS-HT humidity above threshold
|
|
|
+ on_ams_ht_temperature_high: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # AMS-HT temperature above threshold
|
|
|
|
|
|
# Event triggers - Home Assistant sensors bound to a printer (#1148)
|
|
|
- on_ha_sensor_alert = Column(Boolean, default=False) # Bound HA sensor entered its alert state
|
|
|
+ on_ha_sensor_alert: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # Bound HA sensor entered its alert state
|
|
|
|
|
|
# Event triggers - Home Assistant sensors bound to a storage location (#2824)
|
|
|
# Its own column rather than reusing on_ha_sensor_alert above: that one can
|
|
|
# be scoped to a single printer, and a location alert has no printer to
|
|
|
# scope by, so sharing it would leak drybox alerts to a provider narrowed
|
|
|
# to one printer's sensors.
|
|
|
- on_location_ha_sensor_alert = Column(Boolean, default=False)
|
|
|
+ on_location_ha_sensor_alert: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
|
|
|
|
|
# Event triggers - Build plate detection
|
|
|
- on_plate_not_empty = Column(Boolean, default=True) # Objects detected on plate before print
|
|
|
+ on_plate_not_empty: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=True
|
|
|
+ ) # Objects detected on plate before print
|
|
|
# Off by default: fires after every print, alongside the print-complete alert (#2525)
|
|
|
- on_plate_clear_required = Column(Boolean, default=False) # Print ended, queue gated until plate is confirmed clear
|
|
|
+ on_plate_clear_required: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # Print ended, queue gated until plate is confirmed clear
|
|
|
|
|
|
# Print asked for an outcome verdict (#1898). Defaults ON: it only ever
|
|
|
# fires for prints where the user opted in per-job, so the provider-level
|
|
|
# toggle exists to silence a channel, not to enable the feature.
|
|
|
- on_print_confirm_request = Column(Boolean, default=True)
|
|
|
+ on_print_confirm_request: Mapped[bool | None] = mapped_column(Boolean, default=True)
|
|
|
# How a Telegram provider collects that verdict (#3046): inline URL
|
|
|
# buttons ("buttons", the original behaviour), a thumbs-up/down reaction
|
|
|
# on the message ("reactions"), or both. Reactions need no inbound
|
|
|
# connectivity — the poller fetches them — so they work without an
|
|
|
# external_url. Ignored for every other provider type.
|
|
|
- telegram_verdict_mode = Column(String(16), default="buttons")
|
|
|
+ telegram_verdict_mode: Mapped[str | None] = mapped_column(String(16), default="buttons")
|
|
|
|
|
|
# Event triggers - Bed cooled after print
|
|
|
- on_bed_cooled = Column(Boolean, default=False) # Bed cooled below threshold after print
|
|
|
- on_first_layer_complete = Column(Boolean, default=False) # First layer finished printing
|
|
|
+ on_bed_cooled: Mapped[bool | None] = mapped_column(Boolean, default=False) # Bed cooled below threshold after print
|
|
|
+ on_first_layer_complete: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # First layer finished printing
|
|
|
# Messages another application sends through Bambuddy (POST /notifications/app-message),
|
|
|
# e.g. Bambuddy Orders' "an order needs you". Off by default: nothing new arrives on upgrade.
|
|
|
- on_app_message = Column(Boolean, default=False)
|
|
|
+ on_app_message: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
|
|
|
|
|
# Event triggers - Inventory stock alerts
|
|
|
- on_stock_reorder_alert = Column(Boolean, default=False) # SKU hits reorder point
|
|
|
- on_stock_break_alert = Column(Boolean, default=False) # Stock will run out before replenishment
|
|
|
+ on_stock_reorder_alert: Mapped[bool | None] = mapped_column(Boolean, default=False) # SKU hits reorder point
|
|
|
+ on_stock_break_alert: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # Stock will run out before replenishment
|
|
|
|
|
|
# Event triggers - Print queue
|
|
|
- on_queue_job_added = Column(Boolean, default=False) # Job added to queue
|
|
|
- on_queue_job_assigned = Column(Boolean, default=False) # Model-based job assigned to printer
|
|
|
- on_queue_job_started = Column(Boolean, default=False) # Queue job started printing
|
|
|
- on_queue_job_waiting = Column(Boolean, default=True) # Job waiting for filament or printer
|
|
|
- on_queue_job_skipped = Column(Boolean, default=True) # Job skipped (previous print failed)
|
|
|
- on_queue_job_failed = Column(Boolean, default=True) # Job failed to start
|
|
|
- on_queue_completed = Column(Boolean, default=False) # All pending jobs finished
|
|
|
+ on_queue_job_added: Mapped[bool | None] = mapped_column(Boolean, default=False) # Job added to queue
|
|
|
+ on_queue_job_assigned: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=False
|
|
|
+ ) # Model-based job assigned to printer
|
|
|
+ on_queue_job_started: Mapped[bool | None] = mapped_column(Boolean, default=False) # Queue job started printing
|
|
|
+ on_queue_job_waiting: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=True
|
|
|
+ ) # Job waiting for filament or printer
|
|
|
+ on_queue_job_skipped: Mapped[bool | None] = mapped_column(
|
|
|
+ Boolean, default=True
|
|
|
+ ) # Job skipped (previous print failed)
|
|
|
+ on_queue_job_failed: Mapped[bool | None] = mapped_column(Boolean, default=True) # Job failed to start
|
|
|
+ on_queue_completed: Mapped[bool | None] = mapped_column(Boolean, default=False) # All pending jobs finished
|
|
|
|
|
|
# Quiet hours (do not disturb)
|
|
|
- quiet_hours_enabled = Column(Boolean, default=False)
|
|
|
- quiet_hours_start = Column(String(5), nullable=True) # HH:MM format, e.g., "22:00"
|
|
|
- quiet_hours_end = Column(String(5), nullable=True) # HH:MM format, e.g., "07:00"
|
|
|
+ quiet_hours_enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
|
|
+ quiet_hours_start: Mapped[str | None] = mapped_column(String(5), nullable=True) # HH:MM format, e.g., "22:00"
|
|
|
+ quiet_hours_end: Mapped[str | None] = mapped_column(String(5), nullable=True) # HH:MM format, e.g., "07:00"
|
|
|
|
|
|
# Daily digest (batch notifications into a single daily summary)
|
|
|
- daily_digest_enabled = Column(Boolean, default=False)
|
|
|
- daily_digest_time = Column(String(5), nullable=True) # HH:MM format, e.g., "08:00"
|
|
|
+ daily_digest_enabled: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
|
|
+ daily_digest_time: Mapped[str | None] = mapped_column(String(5), nullable=True) # HH:MM format, e.g., "08:00"
|
|
|
|
|
|
# Optional: Link to specific printer (NULL = all printers)
|
|
|
- printer_id = Column(Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True)
|
|
|
+ printer_id: Mapped[int | None] = mapped_column(
|
|
|
+ Integer, ForeignKey("printers.id", ondelete="SET NULL"), nullable=True
|
|
|
+ )
|
|
|
|
|
|
# Status tracking
|
|
|
- last_success = Column(DateTime, nullable=True)
|
|
|
- last_error = Column(Text, nullable=True)
|
|
|
- last_error_at = Column(DateTime, nullable=True)
|
|
|
+ last_success: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
|
+ last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
+ last_error_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
|
|
|
|
|
# Timestamps
|
|
|
- created_at = Column(DateTime, default=datetime.utcnow)
|
|
|
- updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
+ created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow)
|
|
|
+ updated_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
|
|
|
|
|
# Relationships
|
|
|
- printer = relationship("Printer", back_populates="notification_providers")
|
|
|
- logs = relationship("NotificationLog", back_populates="provider", cascade="all, delete-orphan")
|
|
|
- digest_queue = relationship("NotificationDigestQueue", back_populates="provider", cascade="all, delete-orphan")
|
|
|
+ printer: Mapped["Printer | None"] = relationship(back_populates="notification_providers")
|
|
|
+ logs: Mapped[list["NotificationLog"]] = relationship(back_populates="provider", cascade="all, delete-orphan")
|
|
|
+ digest_queue: Mapped[list["NotificationDigestQueue"]] = relationship(
|
|
|
+ back_populates="provider", cascade="all, delete-orphan"
|
|
|
+ )
|
|
|
# ORM-level cascade like the two above: SQLite ships with foreign keys
|
|
|
# off, so the column's ON DELETE CASCADE alone would leave rows behind.
|
|
|
- pending_verdicts = relationship("TelegramPendingVerdict", back_populates="provider", cascade="all, delete-orphan")
|
|
|
+ pending_verdicts: Mapped[list["TelegramPendingVerdict"]] = relationship(
|
|
|
+ back_populates="provider", cascade="all, delete-orphan"
|
|
|
+ )
|
|
|
|
|
|
|
|
|
class TelegramPendingVerdict(Base):
|
|
|
@@ -178,22 +226,26 @@ class TelegramPendingVerdict(Base):
|
|
|
|
|
|
__tablename__ = "telegram_pending_verdicts"
|
|
|
|
|
|
- id = Column(Integer, primary_key=True, index=True)
|
|
|
- provider_id = Column(Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False)
|
|
|
- chat_id = Column(String(64), nullable=False)
|
|
|
- message_id = Column(Integer, nullable=False)
|
|
|
- archive_id = Column(Integer, ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=False)
|
|
|
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
|
+ provider_id: Mapped[int] = mapped_column(
|
|
|
+ Integer, ForeignKey("notification_providers.id", ondelete="CASCADE"), nullable=False
|
|
|
+ )
|
|
|
+ chat_id: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
|
+ message_id: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
|
+ archive_id: Mapped[int] = mapped_column(
|
|
|
+ Integer, ForeignKey("print_archives.id", ondelete="CASCADE"), nullable=False
|
|
|
+ )
|
|
|
# The archive's confirm_token when the prompt went out. A reaction counts
|
|
|
# only while that is still the archive's live token, i.e. exactly as long
|
|
|
# as the prompt's one-tap links would: a reprint clears the token, so an
|
|
|
# earlier run's message can no longer answer the new run.
|
|
|
- confirm_token = Column(String(64), nullable=True)
|
|
|
+ confirm_token: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
# sendPhoto messages carry the prompt as a caption, which is edited with a
|
|
|
# different Bot API method than a plain text message.
|
|
|
- has_caption = Column(Boolean, default=False)
|
|
|
+ has_caption: Mapped[bool | None] = mapped_column(Boolean, default=False)
|
|
|
# The Markdown we sent, so the confirmation edit can append to it — the
|
|
|
# reaction update does not carry the message body.
|
|
|
- message_text = Column(Text, nullable=True)
|
|
|
- created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
|
|
+ message_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
|
+ created_at: Mapped[datetime | None] = mapped_column(DateTime, default=datetime.utcnow, index=True)
|
|
|
|
|
|
- provider = relationship("NotificationProvider", back_populates="pending_verdicts")
|
|
|
+ provider: Mapped["NotificationProvider"] = relationship(back_populates="pending_verdicts")
|