|
|
@@ -2,6 +2,7 @@
|
|
|
|
|
|
import json
|
|
|
import logging
|
|
|
+import re
|
|
|
import smtplib
|
|
|
from datetime import datetime
|
|
|
from email.mime.multipart import MIMEMultipart
|
|
|
@@ -14,8 +15,7 @@ from sqlalchemy import select
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
from backend.app.models.notification import NotificationProvider
|
|
|
-from backend.app.models.settings import Settings
|
|
|
-from backend.app.i18n import Translator
|
|
|
+from backend.app.models.notification_template import NotificationTemplate
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@@ -25,6 +25,7 @@ class NotificationService:
|
|
|
|
|
|
def __init__(self):
|
|
|
self._http_client: httpx.AsyncClient | None = None
|
|
|
+ self._template_cache: dict[str, NotificationTemplate] = {}
|
|
|
|
|
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
|
"""Get or create HTTP client."""
|
|
|
@@ -66,136 +67,77 @@ class NotificationService:
|
|
|
logger.warning(f"Invalid quiet hours format for provider {provider.name}")
|
|
|
return False
|
|
|
|
|
|
- async def _get_notification_language(self, db: AsyncSession) -> str:
|
|
|
- """Get the notification language from settings."""
|
|
|
+ async def _get_template(self, db: AsyncSession, event_type: str) -> NotificationTemplate | None:
|
|
|
+ """Get a notification template by event type."""
|
|
|
+ # Check cache first
|
|
|
+ if event_type in self._template_cache:
|
|
|
+ return self._template_cache[event_type]
|
|
|
+
|
|
|
result = await db.execute(
|
|
|
- select(Settings).where(Settings.key == "notification_language")
|
|
|
+ select(NotificationTemplate).where(NotificationTemplate.event_type == event_type)
|
|
|
)
|
|
|
- setting = result.scalar_one_or_none()
|
|
|
- return setting.value if setting else "en"
|
|
|
+ template = result.scalar_one_or_none()
|
|
|
+
|
|
|
+ if template:
|
|
|
+ self._template_cache[event_type] = template
|
|
|
+
|
|
|
+ return template
|
|
|
+
|
|
|
+ def _render_template(self, template_str: str, variables: dict[str, Any]) -> str:
|
|
|
+ """Render a template string with variables. Missing variables become empty."""
|
|
|
+ result = template_str
|
|
|
+ for key, value in variables.items():
|
|
|
+ result = result.replace("{" + key + "}", str(value) if value is not None else "")
|
|
|
+ # Remove any remaining unreplaced placeholders
|
|
|
+ result = re.sub(r"\{[a-z_]+\}", "", result)
|
|
|
+ return result
|
|
|
|
|
|
- def _format_duration(self, seconds: int | None, translator: Translator) -> str:
|
|
|
+ def _format_duration(self, seconds: int | None) -> str:
|
|
|
"""Format duration in seconds to human-readable string."""
|
|
|
if seconds is None:
|
|
|
- return translator.t("notification.unknown")
|
|
|
+ return "Unknown"
|
|
|
hours = seconds // 3600
|
|
|
minutes = (seconds % 3600) // 60
|
|
|
if hours > 0:
|
|
|
return f"{hours}h {minutes}m"
|
|
|
return f"{minutes}m"
|
|
|
|
|
|
- def _build_print_start_message(self, printer_name: str, data: dict, translator: Translator) -> tuple[str, str]:
|
|
|
- """Build notification message for print start event."""
|
|
|
- filename = data.get("filename", translator.t("notification.unknown"))
|
|
|
- # Clean up filename
|
|
|
+ def _clean_filename(self, filename: str) -> str:
|
|
|
+ """Remove file extensions from filename."""
|
|
|
if filename.endswith(".gcode.3mf"):
|
|
|
- filename = filename[:-10]
|
|
|
+ return filename[:-10]
|
|
|
elif filename.endswith(".3mf"):
|
|
|
- filename = filename[:-4]
|
|
|
-
|
|
|
- title = translator.t("notification.print_started")
|
|
|
-
|
|
|
- estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
|
|
|
- time_str = self._format_duration(estimated_time * 60 if estimated_time else None, translator)
|
|
|
-
|
|
|
- message = f"{printer_name}: {filename}\n{translator.t('notification.estimated')}: {time_str}"
|
|
|
- return title, message
|
|
|
+ return filename[:-4]
|
|
|
+ return filename
|
|
|
|
|
|
- def _build_print_complete_message(
|
|
|
- self, printer_name: str, status: str, data: dict, translator: Translator, archive_data: dict | None = None
|
|
|
+ async def _build_message_from_template(
|
|
|
+ self, db: AsyncSession, event_type: str, variables: dict[str, Any]
|
|
|
) -> tuple[str, str]:
|
|
|
- """Build notification message for print complete event."""
|
|
|
- filename = data.get("filename", translator.t("notification.unknown"))
|
|
|
- if filename.endswith(".gcode.3mf"):
|
|
|
- filename = filename[:-10]
|
|
|
- elif filename.endswith(".3mf"):
|
|
|
- filename = filename[:-4]
|
|
|
+ """Build notification title and body from template."""
|
|
|
+ # Add common variables
|
|
|
+ variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
|
+ variables["app_name"] = "BambuTrack"
|
|
|
|
|
|
- if status == "completed":
|
|
|
- title = translator.t("notification.print_completed")
|
|
|
- elif status == "failed":
|
|
|
- title = translator.t("notification.print_failed")
|
|
|
- elif status in ("aborted", "stopped", "cancelled"):
|
|
|
- title = translator.t("notification.print_stopped")
|
|
|
- else:
|
|
|
- title = translator.t("notification.print_ended")
|
|
|
+ template = await self._get_template(db, event_type)
|
|
|
+ if not template:
|
|
|
+ # Fallback to simple message
|
|
|
+ logger.warning(f"Template not found for event type: {event_type}")
|
|
|
+ return event_type.replace("_", " ").title(), str(variables)
|
|
|
|
|
|
- lines = [f"{printer_name}: {filename}"]
|
|
|
+ title = self._render_template(template.title_template, variables)
|
|
|
+ body = self._render_template(template.body_template, variables)
|
|
|
|
|
|
- if archive_data:
|
|
|
- # Add print time if available
|
|
|
- if archive_data.get("print_time_seconds"):
|
|
|
- lines.append(f"{translator.t('notification.time')}: {self._format_duration(archive_data['print_time_seconds'], translator)}")
|
|
|
- # Add filament used if available
|
|
|
- if archive_data.get("actual_filament_grams"):
|
|
|
- lines.append(f"{translator.t('notification.filament')}: {archive_data['actual_filament_grams']:.1f}g")
|
|
|
- # Add failure reason if failed
|
|
|
- if status == "failed" and archive_data.get("failure_reason"):
|
|
|
- lines.append(f"{translator.t('notification.reason')}: {archive_data['failure_reason']}")
|
|
|
-
|
|
|
- message = "\n".join(lines)
|
|
|
- return title, message
|
|
|
-
|
|
|
- def _build_progress_message(
|
|
|
- self, printer_name: str, filename: str, progress: int, translator: Translator
|
|
|
- ) -> tuple[str, str]:
|
|
|
- """Build notification message for print progress milestone."""
|
|
|
- if filename.endswith(".gcode.3mf"):
|
|
|
- filename = filename[:-10]
|
|
|
- elif filename.endswith(".3mf"):
|
|
|
- filename = filename[:-4]
|
|
|
-
|
|
|
- title = translator.t("notification.print_progress", progress=progress)
|
|
|
- message = f"{printer_name}: {filename}"
|
|
|
- return title, message
|
|
|
-
|
|
|
- def _build_printer_offline_message(self, printer_name: str, translator: Translator) -> tuple[str, str]:
|
|
|
- """Build notification message for printer offline event."""
|
|
|
- title = translator.t("notification.printer_offline")
|
|
|
- message = translator.t("notification.printer_disconnected", printer=printer_name)
|
|
|
- return title, message
|
|
|
-
|
|
|
- def _build_printer_error_message(
|
|
|
- self, printer_name: str, error_type: str, translator: Translator, error_detail: str | None = None
|
|
|
- ) -> tuple[str, str]:
|
|
|
- """Build notification message for printer error event."""
|
|
|
- title = translator.t("notification.printer_error", error_type=error_type)
|
|
|
- message = f"{printer_name}"
|
|
|
- if error_detail:
|
|
|
- message += f"\n{error_detail}"
|
|
|
- return title, message
|
|
|
-
|
|
|
- def _build_filament_low_message(
|
|
|
- self, printer_name: str, slot: int, remaining_percent: int, translator: Translator
|
|
|
- ) -> tuple[str, str]:
|
|
|
- """Build notification message for low filament event."""
|
|
|
- title = translator.t("notification.filament_low")
|
|
|
- message = translator.t("notification.slot_at_percent", printer=printer_name, slot=slot, percent=remaining_percent)
|
|
|
- return title, message
|
|
|
-
|
|
|
- def _build_maintenance_due_message(
|
|
|
- self, printer_name: str, maintenance_items: list[dict], translator: Translator
|
|
|
- ) -> tuple[str, str]:
|
|
|
- """Build notification message for maintenance due event."""
|
|
|
- title = translator.t("notification.maintenance_due")
|
|
|
- lines = [f"{printer_name}:"]
|
|
|
- for item in maintenance_items:
|
|
|
- status = translator.t("notification.overdue") if item.get("is_due") else translator.t("notification.soon")
|
|
|
- lines.append(f"• {item['name']} ({status})")
|
|
|
- message = "\n".join(lines)
|
|
|
- return title, message
|
|
|
+ return title, body
|
|
|
|
|
|
async def send_test_notification(
|
|
|
self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
|
|
|
) -> tuple[bool, str]:
|
|
|
"""Send a test notification to verify configuration."""
|
|
|
- lang = "en"
|
|
|
if db:
|
|
|
- lang = await self._get_notification_language(db)
|
|
|
- translator = Translator(lang)
|
|
|
-
|
|
|
- title = translator.t("notification.test_title")
|
|
|
- message = translator.t("notification.test_message")
|
|
|
+ title, message = await self._build_message_from_template(db, "test", {})
|
|
|
+ else:
|
|
|
+ title = "BambuTrack Test"
|
|
|
+ message = "This is a test notification. If you see this, notifications are working!"
|
|
|
|
|
|
try:
|
|
|
if provider_type == "callmebot":
|
|
|
@@ -461,10 +403,18 @@ class NotificationService:
|
|
|
logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
|
|
|
return
|
|
|
|
|
|
- lang = await self._get_notification_language(db)
|
|
|
- translator = Translator(lang)
|
|
|
+ filename = self._clean_filename(data.get("filename", "Unknown"))
|
|
|
+ estimated_time = data.get("raw_data", {}).get("print", {}).get("mc_remaining_time")
|
|
|
+ time_str = self._format_duration(estimated_time * 60 if estimated_time else None)
|
|
|
+
|
|
|
+ variables = {
|
|
|
+ "printer": printer_name,
|
|
|
+ "filename": filename,
|
|
|
+ "estimated_time": time_str,
|
|
|
+ }
|
|
|
+
|
|
|
logger.info(f"Found {len(providers)} providers for print_start: {[p.name for p in providers]}")
|
|
|
- title, message = self._build_print_start_message(printer_name, data, translator)
|
|
|
+ title, message = await self._build_message_from_template(db, "print_start", variables)
|
|
|
await self._send_to_providers(providers, title, message, db)
|
|
|
|
|
|
async def on_print_complete(
|
|
|
@@ -478,27 +428,47 @@ class NotificationService:
|
|
|
):
|
|
|
"""Handle print complete event - send notifications to relevant providers."""
|
|
|
logger.info(f"on_print_complete called for printer {printer_id} ({printer_name}), status={status}")
|
|
|
- # Determine which event type this is
|
|
|
+
|
|
|
+ # Determine event type based on status
|
|
|
if status == "completed":
|
|
|
event_field = "on_print_complete"
|
|
|
+ event_type = "print_complete"
|
|
|
elif status in ("failed",):
|
|
|
event_field = "on_print_failed"
|
|
|
+ event_type = "print_failed"
|
|
|
elif status in ("aborted", "stopped", "cancelled"):
|
|
|
event_field = "on_print_stopped"
|
|
|
+ event_type = "print_stopped"
|
|
|
else:
|
|
|
- # Unknown status, default to on_print_complete
|
|
|
logger.warning(f"Unknown print status '{status}', defaulting to on_print_complete")
|
|
|
event_field = "on_print_complete"
|
|
|
+ event_type = "print_complete"
|
|
|
|
|
|
providers = await self._get_providers_for_event(db, event_field, printer_id)
|
|
|
if not providers:
|
|
|
logger.info(f"No notification providers configured for {event_field} event on printer {printer_id}")
|
|
|
return
|
|
|
|
|
|
- lang = await self._get_notification_language(db)
|
|
|
- translator = Translator(lang)
|
|
|
+ filename = self._clean_filename(data.get("filename", "Unknown"))
|
|
|
+
|
|
|
+ variables = {
|
|
|
+ "printer": printer_name,
|
|
|
+ "filename": filename,
|
|
|
+ "duration": "",
|
|
|
+ "filament_grams": "",
|
|
|
+ "reason": "",
|
|
|
+ }
|
|
|
+
|
|
|
+ if archive_data:
|
|
|
+ if archive_data.get("print_time_seconds"):
|
|
|
+ variables["duration"] = self._format_duration(archive_data["print_time_seconds"])
|
|
|
+ if archive_data.get("actual_filament_grams"):
|
|
|
+ variables["filament_grams"] = f"{archive_data['actual_filament_grams']:.1f}"
|
|
|
+ if status == "failed" and archive_data.get("failure_reason"):
|
|
|
+ variables["reason"] = archive_data["failure_reason"]
|
|
|
+
|
|
|
logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
|
|
|
- title, message = self._build_print_complete_message(printer_name, status, data, translator, archive_data)
|
|
|
+ title, message = await self._build_message_from_template(db, event_type, variables)
|
|
|
await self._send_to_providers(providers, title, message, db)
|
|
|
|
|
|
async def on_print_progress(
|
|
|
@@ -508,15 +478,21 @@ class NotificationService:
|
|
|
filename: str,
|
|
|
progress: int,
|
|
|
db: AsyncSession,
|
|
|
+ remaining_time: int | None = None,
|
|
|
):
|
|
|
"""Handle print progress milestone (25%, 50%, 75%)."""
|
|
|
providers = await self._get_providers_for_event(db, "on_print_progress", printer_id)
|
|
|
if not providers:
|
|
|
return
|
|
|
|
|
|
- lang = await self._get_notification_language(db)
|
|
|
- translator = Translator(lang)
|
|
|
- title, message = self._build_progress_message(printer_name, filename, progress, translator)
|
|
|
+ variables = {
|
|
|
+ "printer": printer_name,
|
|
|
+ "filename": self._clean_filename(filename),
|
|
|
+ "progress": str(progress),
|
|
|
+ "remaining_time": self._format_duration(remaining_time) if remaining_time else "",
|
|
|
+ }
|
|
|
+
|
|
|
+ title, message = await self._build_message_from_template(db, "print_progress", variables)
|
|
|
await self._send_to_providers(providers, title, message, db)
|
|
|
|
|
|
async def on_printer_offline(
|
|
|
@@ -527,9 +503,9 @@ class NotificationService:
|
|
|
if not providers:
|
|
|
return
|
|
|
|
|
|
- lang = await self._get_notification_language(db)
|
|
|
- translator = Translator(lang)
|
|
|
- title, message = self._build_printer_offline_message(printer_name, translator)
|
|
|
+ variables = {"printer": printer_name}
|
|
|
+
|
|
|
+ title, message = await self._build_message_from_template(db, "printer_offline", variables)
|
|
|
await self._send_to_providers(providers, title, message, db)
|
|
|
|
|
|
async def on_printer_error(
|
|
|
@@ -545,9 +521,13 @@ class NotificationService:
|
|
|
if not providers:
|
|
|
return
|
|
|
|
|
|
- lang = await self._get_notification_language(db)
|
|
|
- translator = Translator(lang)
|
|
|
- title, message = self._build_printer_error_message(printer_name, error_type, translator, error_detail)
|
|
|
+ variables = {
|
|
|
+ "printer": printer_name,
|
|
|
+ "error_type": error_type,
|
|
|
+ "error_detail": error_detail or "",
|
|
|
+ }
|
|
|
+
|
|
|
+ title, message = await self._build_message_from_template(db, "printer_error", variables)
|
|
|
await self._send_to_providers(providers, title, message, db)
|
|
|
|
|
|
async def on_filament_low(
|
|
|
@@ -557,15 +537,21 @@ class NotificationService:
|
|
|
slot: int,
|
|
|
remaining_percent: int,
|
|
|
db: AsyncSession,
|
|
|
+ color: str | None = None,
|
|
|
):
|
|
|
"""Handle low filament event."""
|
|
|
providers = await self._get_providers_for_event(db, "on_filament_low", printer_id)
|
|
|
if not providers:
|
|
|
return
|
|
|
|
|
|
- lang = await self._get_notification_language(db)
|
|
|
- translator = Translator(lang)
|
|
|
- title, message = self._build_filament_low_message(printer_name, slot, remaining_percent, translator)
|
|
|
+ variables = {
|
|
|
+ "printer": printer_name,
|
|
|
+ "slot": str(slot),
|
|
|
+ "remaining_percent": str(remaining_percent),
|
|
|
+ "color": color or "",
|
|
|
+ }
|
|
|
+
|
|
|
+ title, message = await self._build_message_from_template(db, "filament_low", variables)
|
|
|
await self._send_to_providers(providers, title, message, db)
|
|
|
|
|
|
async def on_maintenance_due(
|
|
|
@@ -584,12 +570,26 @@ class NotificationService:
|
|
|
logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
|
|
|
return
|
|
|
|
|
|
- lang = await self._get_notification_language(db)
|
|
|
- translator = Translator(lang)
|
|
|
+ # Format maintenance items list
|
|
|
+ items_list = []
|
|
|
+ for item in maintenance_items:
|
|
|
+ status = "OVERDUE" if item.get("is_due") else "Soon"
|
|
|
+ items_list.append(f"- {item['name']} ({status})")
|
|
|
+ items_str = "\n".join(items_list)
|
|
|
+
|
|
|
+ variables = {
|
|
|
+ "printer": printer_name,
|
|
|
+ "items": items_str,
|
|
|
+ }
|
|
|
+
|
|
|
logger.info(f"Found {len(providers)} providers for maintenance_due: {[p.name for p in providers]}")
|
|
|
- title, message = self._build_maintenance_due_message(printer_name, maintenance_items, translator)
|
|
|
+ title, message = await self._build_message_from_template(db, "maintenance_due", variables)
|
|
|
await self._send_to_providers(providers, title, message, db)
|
|
|
|
|
|
+ def clear_template_cache(self):
|
|
|
+ """Clear the template cache. Call this when templates are updated."""
|
|
|
+ self._template_cache.clear()
|
|
|
+
|
|
|
|
|
|
# Global instance
|
|
|
notification_service = NotificationService()
|