mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 19:21:33 +02:00
- New APIBrowser component with full OpenAPI schema integration
- Fetches and parses /openapi.json automatically
- Groups endpoints by API tags (printers, archives, settings, etc.)
- Expandable endpoint sections with color-coded method badges
- Path parameter, query parameter, and JSON body editors
- Auto-populates request body with schema examples
- Live API request execution with response display
- Response shows status code, timing, and formatted JSON
- Copy response button with clipboard fallback
- Search to filter endpoints across all categories
- Expand All / Collapse All buttons
- Link to Swagger UI (/docs)
- Two-column layout for API Keys tab
- Left: API key management + webhook documentation
- Right: API Browser with dedicated test key input
- Parameter validation
- Shows warning for missing required parameters
- Validates before sending requests to avoid 422 errors
- UX improvements
- "Use in API Browser" button on newly created keys
- Responsive layout (stacked on mobile, side-by-side on xl+)
1056 lines
42 KiB
Python
1056 lines
42 KiB
Python
"""Notification service for sending push notifications via various providers."""
|
|
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import re
|
|
import smtplib
|
|
from datetime import datetime
|
|
from email.mime.multipart import MIMEMultipart
|
|
from email.mime.text import MIMEText
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
import httpx
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from backend.app.models.notification import NotificationDigestQueue, NotificationLog, NotificationProvider
|
|
from backend.app.models.notification_template import NotificationTemplate
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class NotificationService:
|
|
"""Service for sending notifications through various providers."""
|
|
|
|
def __init__(self):
|
|
self._http_client: httpx.AsyncClient | None = None
|
|
self._template_cache: dict[str, NotificationTemplate] = {}
|
|
self._digest_scheduler_task: asyncio.Task | None = None
|
|
self._last_digest_check: str = "" # "HH:MM" to avoid duplicate checks
|
|
|
|
async def _get_client(self) -> httpx.AsyncClient:
|
|
"""Get or create HTTP client."""
|
|
if self._http_client is None or self._http_client.is_closed:
|
|
self._http_client = httpx.AsyncClient(timeout=30.0)
|
|
return self._http_client
|
|
|
|
async def close(self):
|
|
"""Close HTTP client."""
|
|
if self._http_client and not self._http_client.is_closed:
|
|
await self._http_client.aclose()
|
|
|
|
def _is_in_quiet_hours(self, provider: NotificationProvider) -> bool:
|
|
"""Check if current time is within provider's quiet hours."""
|
|
if not provider.quiet_hours_enabled:
|
|
return False
|
|
|
|
if not provider.quiet_hours_start or not provider.quiet_hours_end:
|
|
return False
|
|
|
|
try:
|
|
now = datetime.now()
|
|
current_time = now.hour * 60 + now.minute
|
|
|
|
start_parts = provider.quiet_hours_start.split(":")
|
|
end_parts = provider.quiet_hours_end.split(":")
|
|
|
|
start_minutes = int(start_parts[0]) * 60 + int(start_parts[1])
|
|
end_minutes = int(end_parts[0]) * 60 + int(end_parts[1])
|
|
|
|
# Handle overnight quiet hours (e.g., 22:00 to 07:00)
|
|
if start_minutes > end_minutes:
|
|
# Quiet hours span midnight
|
|
return current_time >= start_minutes or current_time < end_minutes
|
|
else:
|
|
# Same day quiet hours
|
|
return start_minutes <= current_time < end_minutes
|
|
except (ValueError, TypeError, AttributeError):
|
|
logger.warning(f"Invalid quiet hours format for provider {provider.name}")
|
|
return False
|
|
|
|
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(NotificationTemplate).where(NotificationTemplate.event_type == event_type))
|
|
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) -> str:
|
|
"""Format duration in seconds to human-readable string."""
|
|
if seconds is None:
|
|
return "Unknown"
|
|
hours = seconds // 3600
|
|
minutes = (seconds % 3600) // 60
|
|
if hours > 0:
|
|
return f"{hours}h {minutes}m"
|
|
return f"{minutes}m"
|
|
|
|
def _clean_filename(self, filename: str) -> str:
|
|
"""Extract filename and remove file extensions."""
|
|
import os
|
|
|
|
# Strip path prefix (e.g., /data/Metadata/plate_5.gcode -> plate_5.gcode)
|
|
filename = os.path.basename(filename)
|
|
# Remove common extensions
|
|
if filename.endswith(".gcode.3mf"):
|
|
return filename[:-10]
|
|
elif filename.endswith(".gcode"):
|
|
return filename[:-6]
|
|
elif filename.endswith(".3mf"):
|
|
return filename[:-4]
|
|
return filename
|
|
|
|
async def _build_message_from_template(
|
|
self, db: AsyncSession, event_type: str, variables: dict[str, Any]
|
|
) -> tuple[str, str]:
|
|
"""Build notification title and body from template."""
|
|
# Add common variables
|
|
variables["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
variables["app_name"] = "Bambuddy"
|
|
|
|
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)
|
|
|
|
title = self._render_template(template.title_template, variables)
|
|
body = self._render_template(template.body_template, variables)
|
|
|
|
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."""
|
|
if db:
|
|
title, message = await self._build_message_from_template(db, "test", {})
|
|
else:
|
|
title = "Bambuddy Test"
|
|
message = "This is a test notification. If you see this, notifications are working!"
|
|
|
|
try:
|
|
if provider_type == "callmebot":
|
|
return await self._send_callmebot(config, f"{title}\n{message}")
|
|
elif provider_type == "ntfy":
|
|
return await self._send_ntfy(config, title, message)
|
|
elif provider_type == "pushover":
|
|
return await self._send_pushover(config, title, message)
|
|
elif provider_type == "telegram":
|
|
return await self._send_telegram(config, f"*{title}*\n{message}")
|
|
elif provider_type == "email":
|
|
return await self._send_email(config, title, message)
|
|
elif provider_type == "discord":
|
|
return await self._send_discord(config, title, message)
|
|
elif provider_type == "webhook":
|
|
return await self._send_webhook(config, title, message)
|
|
else:
|
|
return False, f"Unknown provider type: {provider_type}"
|
|
except Exception as e:
|
|
logger.exception(f"Error sending test notification via {provider_type}")
|
|
return False, str(e)
|
|
|
|
async def _send_callmebot(self, config: dict, message: str) -> tuple[bool, str]:
|
|
"""Send notification via CallMeBot (WhatsApp)."""
|
|
phone = config.get("phone", "").strip()
|
|
apikey = config.get("apikey", "").strip()
|
|
|
|
if not phone or not apikey:
|
|
return False, "Phone number and API key are required"
|
|
|
|
# URL encode the message
|
|
encoded_message = quote(message)
|
|
url = f"https://api.callmebot.com/whatsapp.php?phone={phone}&text={encoded_message}&apikey={apikey}"
|
|
|
|
client = await self._get_client()
|
|
response = await client.get(url)
|
|
|
|
if response.status_code == 200:
|
|
return True, "Message sent successfully"
|
|
else:
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
|
|
|
async def _send_ntfy(self, config: dict, title: str, message: str) -> tuple[bool, str]:
|
|
"""Send notification via ntfy."""
|
|
server = config.get("server", "https://ntfy.sh").rstrip("/")
|
|
topic = config.get("topic", "").strip()
|
|
auth_token = config.get("auth_token", "").strip()
|
|
|
|
if not topic:
|
|
return False, "Topic is required"
|
|
|
|
url = f"{server}/{topic}"
|
|
headers = {"Title": title}
|
|
|
|
if auth_token:
|
|
headers["Authorization"] = f"Bearer {auth_token}"
|
|
|
|
client = await self._get_client()
|
|
response = await client.post(url, content=message, headers=headers)
|
|
|
|
if response.status_code in (200, 204):
|
|
return True, "Message sent successfully"
|
|
else:
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
|
|
|
async def _send_pushover(self, config: dict, title: str, message: str) -> tuple[bool, str]:
|
|
"""Send notification via Pushover."""
|
|
user_key = config.get("user_key", "").strip()
|
|
app_token = config.get("app_token", "").strip()
|
|
priority = config.get("priority", 0)
|
|
|
|
if not user_key or not app_token:
|
|
return False, "User key and app token are required"
|
|
|
|
url = "https://api.pushover.net/1/messages.json"
|
|
data = {
|
|
"token": app_token,
|
|
"user": user_key,
|
|
"title": title,
|
|
"message": message,
|
|
"priority": priority,
|
|
}
|
|
|
|
client = await self._get_client()
|
|
response = await client.post(url, data=data)
|
|
|
|
if response.status_code == 200:
|
|
return True, "Message sent successfully"
|
|
else:
|
|
try:
|
|
error_data = response.json()
|
|
errors = error_data.get("errors", [])
|
|
return False, f"Pushover error: {', '.join(errors)}"
|
|
except Exception:
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
|
|
|
async def _send_telegram(self, config: dict, message: str) -> tuple[bool, str]:
|
|
"""Send notification via Telegram bot."""
|
|
bot_token = config.get("bot_token", "").strip()
|
|
chat_id = config.get("chat_id", "").strip()
|
|
|
|
if not bot_token or not chat_id:
|
|
return False, "Bot token and chat ID are required"
|
|
|
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
|
data = {
|
|
"chat_id": chat_id,
|
|
"text": message,
|
|
"parse_mode": "Markdown",
|
|
}
|
|
|
|
client = await self._get_client()
|
|
response = await client.post(url, json=data)
|
|
|
|
if response.status_code == 200:
|
|
result = response.json()
|
|
if result.get("ok"):
|
|
return True, "Message sent successfully"
|
|
else:
|
|
return False, f"Telegram error: {result.get('description', 'Unknown error')}"
|
|
else:
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
|
|
|
async def _send_email(self, config: dict, subject: str, body: str) -> tuple[bool, str]:
|
|
"""Send notification via email (SMTP)."""
|
|
smtp_server = config.get("smtp_server", "").strip()
|
|
smtp_port = int(config.get("smtp_port", 587))
|
|
username = config.get("username", "").strip()
|
|
password = config.get("password", "").strip()
|
|
from_email = config.get("from_email", "").strip()
|
|
to_email = config.get("to_email", "").strip()
|
|
# Security: "starttls" (port 587), "ssl" (port 465), "none" (port 25)
|
|
security = config.get("security", "starttls")
|
|
# Authentication: "true" or "false"
|
|
auth_enabled = config.get("auth_enabled", "true").lower() == "true"
|
|
|
|
if not all([smtp_server, from_email, to_email]):
|
|
return False, "SMTP server, from email, and to email are required"
|
|
|
|
if auth_enabled and not all([username, password]):
|
|
return False, "Username and password are required when authentication is enabled"
|
|
|
|
try:
|
|
msg = MIMEMultipart()
|
|
msg["From"] = from_email
|
|
msg["To"] = to_email
|
|
msg["Subject"] = f"[Bambuddy] {subject}"
|
|
msg.attach(MIMEText(body, "plain"))
|
|
|
|
if security == "ssl":
|
|
# Direct SSL connection (typically port 465)
|
|
server = smtplib.SMTP_SSL(smtp_server, smtp_port)
|
|
elif security == "starttls":
|
|
# STARTTLS upgrade (typically port 587)
|
|
server = smtplib.SMTP(smtp_server, smtp_port)
|
|
server.starttls()
|
|
else:
|
|
# No encryption (typically port 25) - use with caution
|
|
server = smtplib.SMTP(smtp_server, smtp_port)
|
|
|
|
if auth_enabled:
|
|
server.login(username, password)
|
|
|
|
server.sendmail(from_email, to_email, msg.as_string())
|
|
server.quit()
|
|
|
|
return True, "Email sent successfully"
|
|
except smtplib.SMTPAuthenticationError:
|
|
return False, "SMTP authentication failed - check username/password"
|
|
except smtplib.SMTPException as e:
|
|
return False, f"SMTP error: {str(e)}"
|
|
except Exception as e:
|
|
return False, f"Email error: {str(e)}"
|
|
|
|
async def _send_discord(self, config: dict, title: str, message: str) -> tuple[bool, str]:
|
|
"""Send notification via Discord webhook."""
|
|
webhook_url = config.get("webhook_url", "").strip()
|
|
|
|
if not webhook_url:
|
|
return False, "Webhook URL is required"
|
|
|
|
if not webhook_url.startswith("https://discord.com/api/webhooks/"):
|
|
return False, "Invalid Discord webhook URL"
|
|
|
|
# Discord embed format for nicer messages
|
|
data = {
|
|
"embeds": [
|
|
{
|
|
"title": title,
|
|
"description": message,
|
|
"color": 0x00AE42, # Bambu green
|
|
}
|
|
]
|
|
}
|
|
|
|
client = await self._get_client()
|
|
response = await client.post(webhook_url, json=data)
|
|
|
|
if response.status_code in (200, 204):
|
|
return True, "Message sent successfully"
|
|
else:
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
|
|
|
async def _send_webhook(self, config: dict, title: str, message: str) -> tuple[bool, str]:
|
|
"""Send notification via generic webhook (POST JSON)."""
|
|
webhook_url = config.get("webhook_url", "").strip()
|
|
auth_header = config.get("auth_header", "").strip()
|
|
custom_field_title = config.get("field_title", "title").strip() or "title"
|
|
custom_field_message = config.get("field_message", "message").strip() or "message"
|
|
|
|
if not webhook_url:
|
|
return False, "Webhook URL is required"
|
|
|
|
# Build payload with custom field names
|
|
data = {
|
|
custom_field_title: title,
|
|
custom_field_message: message,
|
|
"timestamp": datetime.now().isoformat(),
|
|
"source": "Bambuddy",
|
|
}
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
if auth_header:
|
|
# Support "Bearer token" or just "token" format
|
|
if " " in auth_header:
|
|
headers["Authorization"] = auth_header
|
|
else:
|
|
headers["Authorization"] = f"Bearer {auth_header}"
|
|
|
|
client = await self._get_client()
|
|
try:
|
|
response = await client.post(webhook_url, json=data, headers=headers)
|
|
|
|
if response.status_code in (200, 201, 202, 204):
|
|
return True, "Webhook delivered successfully"
|
|
else:
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
|
except Exception as e:
|
|
return False, f"Webhook error: {str(e)}"
|
|
|
|
async def _send_to_provider(
|
|
self, provider: NotificationProvider, title: str, message: str, db: AsyncSession | None = None
|
|
) -> tuple[bool, str]:
|
|
"""Send notification to a specific provider."""
|
|
# Check quiet hours
|
|
if self._is_in_quiet_hours(provider):
|
|
logger.info(f"Skipping notification to {provider.name} - quiet hours active")
|
|
return True, "Skipped - quiet hours"
|
|
|
|
config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
|
|
|
|
try:
|
|
if provider.provider_type == "callmebot":
|
|
return await self._send_callmebot(config, f"{title}\n{message}")
|
|
elif provider.provider_type == "ntfy":
|
|
return await self._send_ntfy(config, title, message)
|
|
elif provider.provider_type == "pushover":
|
|
return await self._send_pushover(config, title, message)
|
|
elif provider.provider_type == "telegram":
|
|
return await self._send_telegram(config, f"*{title}*\n{message}")
|
|
elif provider.provider_type == "email":
|
|
return await self._send_email(config, title, message)
|
|
elif provider.provider_type == "discord":
|
|
return await self._send_discord(config, title, message)
|
|
elif provider.provider_type == "webhook":
|
|
return await self._send_webhook(config, title, message)
|
|
else:
|
|
return False, f"Unknown provider type: {provider.provider_type}"
|
|
except Exception as e:
|
|
logger.exception(f"Error sending notification via {provider.provider_type}")
|
|
return False, str(e)
|
|
|
|
async def _update_provider_status(
|
|
self, db: AsyncSession, provider_id: int, success: bool, error: str | None = None
|
|
):
|
|
"""Update provider status after sending notification."""
|
|
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
|
provider = result.scalar_one_or_none()
|
|
if provider:
|
|
if success:
|
|
provider.last_success = datetime.utcnow()
|
|
else:
|
|
provider.last_error = error
|
|
provider.last_error_at = datetime.utcnow()
|
|
await db.commit()
|
|
|
|
async def _get_providers_for_event(
|
|
self,
|
|
db: AsyncSession,
|
|
event_field: str,
|
|
printer_id: int | None = None,
|
|
) -> list[NotificationProvider]:
|
|
"""Get all enabled providers that want a specific event type."""
|
|
# Build the query dynamically based on event field
|
|
query = select(NotificationProvider).where(
|
|
NotificationProvider.enabled.is_(True),
|
|
getattr(NotificationProvider, event_field).is_(True),
|
|
)
|
|
|
|
if printer_id is not None:
|
|
query = query.where(
|
|
(NotificationProvider.printer_id.is_(None)) | (NotificationProvider.printer_id == printer_id)
|
|
)
|
|
|
|
result = await db.execute(query)
|
|
return list(result.scalars().all())
|
|
|
|
async def _log_notification(
|
|
self,
|
|
db: AsyncSession,
|
|
provider_id: int,
|
|
event_type: str,
|
|
title: str,
|
|
message: str,
|
|
success: bool,
|
|
error_message: str | None = None,
|
|
printer_id: int | None = None,
|
|
printer_name: str | None = None,
|
|
):
|
|
"""Create a log entry for a sent notification."""
|
|
try:
|
|
log = NotificationLog(
|
|
provider_id=provider_id,
|
|
event_type=event_type,
|
|
title=title,
|
|
message=message,
|
|
success=success,
|
|
error_message=error_message,
|
|
printer_id=printer_id,
|
|
printer_name=printer_name,
|
|
)
|
|
db.add(log)
|
|
await db.commit()
|
|
except Exception as e:
|
|
logger.warning(f"Failed to log notification: {e}")
|
|
# Don't fail the notification just because logging failed
|
|
|
|
async def _send_to_providers(
|
|
self,
|
|
providers: list[NotificationProvider],
|
|
title: str,
|
|
message: str,
|
|
db: AsyncSession,
|
|
event_type: str = "unknown",
|
|
printer_id: int | None = None,
|
|
printer_name: str | None = None,
|
|
force_immediate: bool = False,
|
|
):
|
|
"""Send notification to multiple providers and log the results.
|
|
|
|
All notifications are always sent immediately. If digest mode is enabled,
|
|
the notification is ALSO queued for the daily digest summary.
|
|
"""
|
|
for provider in providers:
|
|
try:
|
|
# Always send notification immediately
|
|
success, error = await self._send_to_provider(provider, title, message, db)
|
|
|
|
# Also queue for digest if enabled (digest is a summary, not a queue)
|
|
if provider.daily_digest_enabled and provider.daily_digest_time:
|
|
await self._queue_for_digest(
|
|
provider=provider,
|
|
event_type=event_type,
|
|
title=title,
|
|
message=message,
|
|
db=db,
|
|
printer_id=printer_id,
|
|
printer_name=printer_name,
|
|
)
|
|
await self._update_provider_status(db, provider.id, success, error if not success else None)
|
|
await self._log_notification(
|
|
db=db,
|
|
provider_id=provider.id,
|
|
event_type=event_type,
|
|
title=title,
|
|
message=message,
|
|
success=success,
|
|
error_message=error if not success else None,
|
|
printer_id=printer_id,
|
|
printer_name=printer_name,
|
|
)
|
|
if success:
|
|
logger.info(f"Sent notification via {provider.name}")
|
|
else:
|
|
logger.warning(f"Failed to send notification via {provider.name}: {error}")
|
|
except Exception as e:
|
|
logger.exception(f"Error sending notification via {provider.name}")
|
|
await self._update_provider_status(db, provider.id, False, str(e))
|
|
await self._log_notification(
|
|
db=db,
|
|
provider_id=provider.id,
|
|
event_type=event_type,
|
|
title=title,
|
|
message=message,
|
|
success=False,
|
|
error_message=str(e),
|
|
printer_id=printer_id,
|
|
printer_name=printer_name,
|
|
)
|
|
|
|
async def on_print_start(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
data: dict,
|
|
db: AsyncSession,
|
|
archive_data: dict | None = None,
|
|
):
|
|
"""Handle print start event - send notifications to relevant providers.
|
|
|
|
Args:
|
|
printer_id: The printer ID
|
|
printer_name: The printer name
|
|
data: MQTT event data with filename, subtask_name, remaining_time, raw_data
|
|
db: Database session
|
|
archive_data: Optional archive data with print_time_seconds from 3MF parsing
|
|
"""
|
|
logger.info(f"on_print_start called for printer {printer_id} ({printer_name})")
|
|
providers = await self._get_providers_for_event(db, "on_print_start", printer_id)
|
|
if not providers:
|
|
logger.info(f"No notification providers configured for print_start event on printer {printer_id}")
|
|
return
|
|
|
|
# Use subtask_name (project name) if available, otherwise use filename
|
|
subtask_name = data.get("subtask_name")
|
|
if subtask_name:
|
|
# Replace underscores with spaces for readability
|
|
filename = subtask_name.replace("_", " ")
|
|
else:
|
|
filename = self._clean_filename(data.get("filename", "Unknown"))
|
|
|
|
# Priority for estimated_time:
|
|
# 1. Archive's print_time_seconds from 3MF parsing (most reliable)
|
|
# 2. MQTT remaining_time (may be 0 at print start)
|
|
# 3. raw_data mc_remaining_time
|
|
estimated_time = None
|
|
|
|
# Try archive data first (from 3MF parsing - most reliable)
|
|
if archive_data and archive_data.get("print_time_seconds"):
|
|
estimated_time = archive_data["print_time_seconds"]
|
|
logger.debug(f"Using print_time_seconds from archive: {estimated_time}")
|
|
|
|
# Fall back to MQTT remaining_time
|
|
if estimated_time is None:
|
|
estimated_time = data.get("remaining_time")
|
|
if estimated_time:
|
|
logger.debug(f"Using remaining_time from MQTT: {estimated_time}")
|
|
|
|
# Last resort: raw_data mc_remaining_time (in minutes, convert to seconds)
|
|
if estimated_time is None:
|
|
raw_time = data.get("raw_data", {}).get("mc_remaining_time")
|
|
if raw_time:
|
|
estimated_time = raw_time * 60
|
|
logger.debug(f"Using mc_remaining_time from raw_data: {estimated_time}")
|
|
|
|
time_str = self._format_duration(estimated_time)
|
|
|
|
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 = await self._build_message_from_template(db, "print_start", variables)
|
|
await self._send_to_providers(providers, title, message, db, "print_start", printer_id, printer_name)
|
|
|
|
async def on_print_complete(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
status: str,
|
|
data: dict,
|
|
db: AsyncSession,
|
|
archive_data: dict | None = None,
|
|
):
|
|
"""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 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:
|
|
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
|
|
|
|
# Use subtask_name (project name) if available, otherwise use filename
|
|
subtask_name = data.get("subtask_name")
|
|
if subtask_name:
|
|
filename = subtask_name.replace("_", " ")
|
|
else:
|
|
filename = self._clean_filename(data.get("filename", "Unknown"))
|
|
|
|
variables = {
|
|
"printer": printer_name,
|
|
"filename": filename,
|
|
"duration": "Unknown",
|
|
"filament_grams": "Unknown",
|
|
"reason": "Unknown",
|
|
}
|
|
|
|
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"on_print_complete variables: {variables}, archive_data: {archive_data}")
|
|
|
|
logger.info(f"Found {len(providers)} providers for {event_field}: {[p.name for p in providers]}")
|
|
title, message = await self._build_message_from_template(db, event_type, variables)
|
|
await self._send_to_providers(providers, title, message, db, event_type, printer_id, printer_name)
|
|
|
|
async def on_print_progress(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
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
|
|
|
|
variables = {
|
|
"printer": printer_name,
|
|
"filename": self._clean_filename(filename),
|
|
"progress": str(progress),
|
|
"remaining_time": self._format_duration(remaining_time) if remaining_time else "Unknown",
|
|
}
|
|
|
|
title, message = await self._build_message_from_template(db, "print_progress", variables)
|
|
await self._send_to_providers(providers, title, message, db, "print_progress", printer_id, printer_name)
|
|
|
|
async def on_printer_offline(self, printer_id: int, printer_name: str, db: AsyncSession):
|
|
"""Handle printer offline event."""
|
|
providers = await self._get_providers_for_event(db, "on_printer_offline", printer_id)
|
|
if not providers:
|
|
return
|
|
|
|
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, "printer_offline", printer_id, printer_name)
|
|
|
|
async def on_printer_error(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
error_type: str,
|
|
db: AsyncSession,
|
|
error_detail: str | None = None,
|
|
):
|
|
"""Handle printer error event (AMS issues, etc.)."""
|
|
providers = await self._get_providers_for_event(db, "on_printer_error", printer_id)
|
|
if not providers:
|
|
return
|
|
|
|
variables = {
|
|
"printer": printer_name,
|
|
"error_type": error_type,
|
|
"error_detail": error_detail or "No details available",
|
|
}
|
|
|
|
title, message = await self._build_message_from_template(db, "printer_error", variables)
|
|
await self._send_to_providers(providers, title, message, db, "printer_error", printer_id, printer_name)
|
|
|
|
async def on_filament_low(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
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
|
|
|
|
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, "filament_low", printer_id, printer_name)
|
|
|
|
async def on_maintenance_due(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
maintenance_items: list[dict],
|
|
db: AsyncSession,
|
|
):
|
|
"""Handle maintenance due event - sends notification when maintenance is due or warning."""
|
|
if not maintenance_items:
|
|
return
|
|
|
|
providers = await self._get_providers_for_event(db, "on_maintenance_due", printer_id)
|
|
if not providers:
|
|
logger.info(f"No notification providers configured for maintenance_due event on printer {printer_id}")
|
|
return
|
|
|
|
# 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 = await self._build_message_from_template(db, "maintenance_due", variables)
|
|
await self._send_to_providers(providers, title, message, db, "maintenance_due", printer_id, printer_name)
|
|
|
|
async def on_ams_humidity_high(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
ams_label: str,
|
|
humidity: float,
|
|
threshold: float,
|
|
db: AsyncSession,
|
|
):
|
|
"""Handle AMS high humidity alarm event. Always sends immediately (bypasses digest)."""
|
|
providers = await self._get_providers_for_event(db, "on_ams_humidity_high", printer_id)
|
|
if not providers:
|
|
return
|
|
|
|
variables = {
|
|
"printer": printer_name,
|
|
"ams_label": ams_label,
|
|
"humidity": f"{humidity:.0f}",
|
|
"threshold": f"{threshold:.0f}",
|
|
}
|
|
|
|
title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
|
|
# Alarms always send immediately, bypassing digest mode
|
|
await self._send_to_providers(
|
|
providers, title, message, db, "ams_humidity_high", printer_id, printer_name, force_immediate=True
|
|
)
|
|
|
|
async def on_ams_temperature_high(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
ams_label: str,
|
|
temperature: float,
|
|
threshold: float,
|
|
db: AsyncSession,
|
|
):
|
|
"""Handle AMS high temperature alarm event. Always sends immediately (bypasses digest)."""
|
|
providers = await self._get_providers_for_event(db, "on_ams_temperature_high", printer_id)
|
|
if not providers:
|
|
return
|
|
|
|
variables = {
|
|
"printer": printer_name,
|
|
"ams_label": ams_label,
|
|
"temperature": f"{temperature:.1f}",
|
|
"threshold": f"{threshold:.1f}",
|
|
}
|
|
|
|
title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
|
|
# Alarms always send immediately, bypassing digest mode
|
|
await self._send_to_providers(
|
|
providers, title, message, db, "ams_temperature_high", printer_id, printer_name, force_immediate=True
|
|
)
|
|
|
|
async def on_ams_ht_humidity_high(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
ams_label: str,
|
|
humidity: float,
|
|
threshold: float,
|
|
db: AsyncSession,
|
|
):
|
|
"""Handle AMS-HT high humidity alarm event. Always sends immediately (bypasses digest)."""
|
|
providers = await self._get_providers_for_event(db, "on_ams_ht_humidity_high", printer_id)
|
|
if not providers:
|
|
return
|
|
|
|
variables = {
|
|
"printer": printer_name,
|
|
"ams_label": ams_label,
|
|
"humidity": f"{humidity:.0f}",
|
|
"threshold": f"{threshold:.0f}",
|
|
}
|
|
|
|
# Use the same template as regular AMS (can create separate templates later if needed)
|
|
title, message = await self._build_message_from_template(db, "ams_humidity_high", variables)
|
|
# Alarms always send immediately, bypassing digest mode
|
|
await self._send_to_providers(
|
|
providers, title, message, db, "ams_ht_humidity_high", printer_id, printer_name, force_immediate=True
|
|
)
|
|
|
|
async def on_ams_ht_temperature_high(
|
|
self,
|
|
printer_id: int,
|
|
printer_name: str,
|
|
ams_label: str,
|
|
temperature: float,
|
|
threshold: float,
|
|
db: AsyncSession,
|
|
):
|
|
"""Handle AMS-HT high temperature alarm event. Always sends immediately (bypasses digest)."""
|
|
providers = await self._get_providers_for_event(db, "on_ams_ht_temperature_high", printer_id)
|
|
if not providers:
|
|
return
|
|
|
|
variables = {
|
|
"printer": printer_name,
|
|
"ams_label": ams_label,
|
|
"temperature": f"{temperature:.1f}",
|
|
"threshold": f"{threshold:.1f}",
|
|
}
|
|
|
|
# Use the same template as regular AMS (can create separate templates later if needed)
|
|
title, message = await self._build_message_from_template(db, "ams_temperature_high", variables)
|
|
# Alarms always send immediately, bypassing digest mode
|
|
await self._send_to_providers(
|
|
providers, title, message, db, "ams_ht_temperature_high", printer_id, printer_name, force_immediate=True
|
|
)
|
|
|
|
def clear_template_cache(self):
|
|
"""Clear the template cache. Call this when templates are updated."""
|
|
self._template_cache.clear()
|
|
|
|
async def _queue_for_digest(
|
|
self,
|
|
provider: NotificationProvider,
|
|
event_type: str,
|
|
title: str,
|
|
message: str,
|
|
db: AsyncSession,
|
|
printer_id: int | None = None,
|
|
printer_name: str | None = None,
|
|
):
|
|
"""Queue a notification for later delivery in the daily digest."""
|
|
try:
|
|
queue_entry = NotificationDigestQueue(
|
|
provider_id=provider.id,
|
|
event_type=event_type,
|
|
title=title,
|
|
message=message,
|
|
printer_id=printer_id,
|
|
printer_name=printer_name,
|
|
)
|
|
db.add(queue_entry)
|
|
await db.commit()
|
|
logger.info(f"Queued notification for digest: {event_type} for provider {provider.name}")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to queue notification for digest: {e}")
|
|
|
|
async def send_digest(self, provider_id: int):
|
|
"""Send all queued notifications as a single digest for a provider."""
|
|
from backend.app.core.database import async_session
|
|
|
|
async with async_session() as db:
|
|
# Get the provider
|
|
result = await db.execute(select(NotificationProvider).where(NotificationProvider.id == provider_id))
|
|
provider = result.scalar_one_or_none()
|
|
|
|
if not provider or not provider.enabled:
|
|
return
|
|
|
|
# Get all queued notifications for this provider
|
|
result = await db.execute(
|
|
select(NotificationDigestQueue)
|
|
.where(NotificationDigestQueue.provider_id == provider_id)
|
|
.order_by(NotificationDigestQueue.created_at)
|
|
)
|
|
queue_entries = list(result.scalars().all())
|
|
|
|
if not queue_entries:
|
|
logger.debug(f"No queued notifications for provider {provider.name}")
|
|
return
|
|
|
|
# Build digest message
|
|
title = f"Daily Digest - {len(queue_entries)} Events"
|
|
|
|
# Group by event type
|
|
events_by_type: dict[str, list] = {}
|
|
for entry in queue_entries:
|
|
if entry.event_type not in events_by_type:
|
|
events_by_type[entry.event_type] = []
|
|
events_by_type[entry.event_type].append(entry)
|
|
|
|
# Format the digest body
|
|
body_parts = []
|
|
for event_type, entries in events_by_type.items():
|
|
event_label = event_type.replace("_", " ").title()
|
|
body_parts.append(f"== {event_label} ({len(entries)}) ==")
|
|
for entry in entries:
|
|
time_str = entry.created_at.strftime("%H:%M")
|
|
printer_info = f"[{entry.printer_name}] " if entry.printer_name else ""
|
|
body_parts.append(f" {time_str} {printer_info}{entry.title}")
|
|
body_parts.append("")
|
|
|
|
body = "\n".join(body_parts)
|
|
|
|
# Send the digest
|
|
success, error = await self._send_to_provider(provider, title, body, db)
|
|
|
|
# Log the digest
|
|
await self._log_notification(
|
|
db=db,
|
|
provider_id=provider.id,
|
|
event_type="daily_digest",
|
|
title=title,
|
|
message=body,
|
|
success=success,
|
|
error_message=error if not success else None,
|
|
)
|
|
|
|
# Clear the queue
|
|
for entry in queue_entries:
|
|
await db.delete(entry)
|
|
await db.commit()
|
|
|
|
if success:
|
|
logger.info(f"Sent daily digest with {len(queue_entries)} events to {provider.name}")
|
|
else:
|
|
logger.warning(f"Failed to send daily digest to {provider.name}: {error}")
|
|
|
|
async def check_and_send_digests(self):
|
|
"""Check all providers and send digests if it's their scheduled time."""
|
|
from backend.app.core.database import async_session
|
|
|
|
current_time = datetime.now().strftime("%H:%M")
|
|
|
|
# Avoid duplicate checks within the same minute
|
|
if current_time == self._last_digest_check:
|
|
return
|
|
self._last_digest_check = current_time
|
|
|
|
async with async_session() as db:
|
|
# Find all providers with digest enabled at this time
|
|
result = await db.execute(
|
|
select(NotificationProvider).where(
|
|
NotificationProvider.enabled.is_(True),
|
|
NotificationProvider.daily_digest_enabled.is_(True),
|
|
NotificationProvider.daily_digest_time == current_time,
|
|
)
|
|
)
|
|
providers = result.scalars().all()
|
|
|
|
for provider in providers:
|
|
try:
|
|
await self.send_digest(provider.id)
|
|
except Exception as e:
|
|
logger.error(f"Error sending digest for provider {provider.id}: {e}")
|
|
|
|
def start_digest_scheduler(self):
|
|
"""Start the background scheduler for daily digest notifications."""
|
|
if self._digest_scheduler_task is None:
|
|
self._digest_scheduler_task = asyncio.create_task(self._digest_scheduler_loop())
|
|
logger.info("Notification digest scheduler started")
|
|
|
|
def stop_digest_scheduler(self):
|
|
"""Stop the background scheduler for daily digests."""
|
|
if self._digest_scheduler_task:
|
|
self._digest_scheduler_task.cancel()
|
|
self._digest_scheduler_task = None
|
|
logger.info("Notification digest scheduler stopped")
|
|
|
|
async def _digest_scheduler_loop(self):
|
|
"""Background loop that checks for scheduled digests every minute."""
|
|
while True:
|
|
try:
|
|
await self.check_and_send_digests()
|
|
except Exception as e:
|
|
logger.error(f"Error in digest scheduler: {e}")
|
|
|
|
# Wait until the next minute
|
|
await asyncio.sleep(60)
|
|
|
|
|
|
# Global instance
|
|
notification_service = NotificationService()
|