|
@@ -26,6 +26,7 @@ from backend.app.models.notification import (
|
|
|
)
|
|
)
|
|
|
from backend.app.models.notification_template import NotificationTemplate
|
|
from backend.app.models.notification_template import NotificationTemplate
|
|
|
from backend.app.services.print_confirmation import one_tap_url
|
|
from backend.app.services.print_confirmation import one_tap_url
|
|
|
|
|
+from backend.app.utils.notification_photos import save_notification_photo
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
@@ -137,6 +138,41 @@ def _opaque_http_failure(response: httpx.Response, *, label: str) -> str:
|
|
|
return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
|
|
return f"HTTP {response.status_code} from the configured {label} (see server logs at debug level for details)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
+_sample_notification_image: bytes | None = None
|
|
|
|
|
+_sample_notification_image_loaded = False
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+def _load_sample_notification_image() -> bytes | None:
|
|
|
|
|
+ """Render the bundled app screenshot as a small JPEG.
|
|
|
|
|
+
|
|
|
|
|
+ Stands in for a real camera snapshot on Test Configuration / test-all,
|
|
|
|
|
+ since there's no live print to capture one from. Cached after the first
|
|
|
|
|
+ call so we're not re-reading and resizing the file on every click.
|
|
|
|
|
+ """
|
|
|
|
|
+ global _sample_notification_image, _sample_notification_image_loaded
|
|
|
|
|
+ if _sample_notification_image_loaded:
|
|
|
|
|
+ return _sample_notification_image
|
|
|
|
|
+ _sample_notification_image_loaded = True
|
|
|
|
|
+ try:
|
|
|
|
|
+ from io import BytesIO
|
|
|
|
|
+
|
|
|
|
|
+ from PIL import Image
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.core.config import settings
|
|
|
|
|
+
|
|
|
|
|
+ source_path = settings.static_dir / "img" / "screenshot-desktop.png"
|
|
|
|
|
+ with Image.open(source_path) as img:
|
|
|
|
|
+ resized = img.convert("RGB")
|
|
|
|
|
+ resized.thumbnail((640, 640))
|
|
|
|
|
+ buffer = BytesIO()
|
|
|
|
|
+ resized.save(buffer, format="JPEG", quality=85)
|
|
|
|
|
+ _sample_notification_image = buffer.getvalue()
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning("Failed to build sample notification image: %s", e)
|
|
|
|
|
+ _sample_notification_image = None
|
|
|
|
|
+ return _sample_notification_image
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
class NotificationService:
|
|
class NotificationService:
|
|
|
"""Service for sending notifications through various providers."""
|
|
"""Service for sending notifications through various providers."""
|
|
|
|
|
|
|
@@ -288,34 +324,51 @@ class NotificationService:
|
|
|
return title, body
|
|
return title, body
|
|
|
|
|
|
|
|
async def send_test_notification(
|
|
async def send_test_notification(
|
|
|
- self, provider_type: str, config: dict[str, Any], db: AsyncSession | None = None
|
|
|
|
|
|
|
+ self,
|
|
|
|
|
+ provider_type: str,
|
|
|
|
|
+ config: dict[str, Any],
|
|
|
|
|
+ db: AsyncSession | None = None,
|
|
|
|
|
+ attach_photo: bool = True,
|
|
|
) -> tuple[bool, str]:
|
|
) -> tuple[bool, str]:
|
|
|
- """Send a test notification to verify configuration."""
|
|
|
|
|
|
|
+ """Send a test notification to verify configuration.
|
|
|
|
|
+
|
|
|
|
|
+ attach_photo mirrors the provider's own toggle (see _send_to_provider)
|
|
|
|
|
+ — when set, a bundled sample image stands in for a real snapshot.
|
|
|
|
|
+ """
|
|
|
if db:
|
|
if db:
|
|
|
title, message = await self._build_message_from_template(db, "test", {})
|
|
title, message = await self._build_message_from_template(db, "test", {})
|
|
|
else:
|
|
else:
|
|
|
title = "Bambuddy Test"
|
|
title = "Bambuddy Test"
|
|
|
message = "This is a test notification. If you see this, notifications are working!"
|
|
message = "This is a test notification. If you see this, notifications are working!"
|
|
|
|
|
|
|
|
|
|
+ image_data = await asyncio.to_thread(_load_sample_notification_image) if attach_photo else None
|
|
|
|
|
+
|
|
|
try:
|
|
try:
|
|
|
if provider_type == "callmebot":
|
|
if provider_type == "callmebot":
|
|
|
return await self._send_callmebot(config, f"{title}\n{message}")
|
|
return await self._send_callmebot(config, f"{title}\n{message}")
|
|
|
elif provider_type == "ntfy":
|
|
elif provider_type == "ntfy":
|
|
|
- return await self._send_ntfy(config, title, message)
|
|
|
|
|
|
|
+ return await self._send_ntfy(config, title, message, image_data=image_data)
|
|
|
elif provider_type == "pushover":
|
|
elif provider_type == "pushover":
|
|
|
- return await self._send_pushover(config, title, message)
|
|
|
|
|
|
|
+ return await self._send_pushover(config, title, message, image_data=image_data)
|
|
|
elif provider_type == "telegram":
|
|
elif provider_type == "telegram":
|
|
|
- return await self._send_telegram(config, f"*{title}*\n{message}")
|
|
|
|
|
|
|
+ return await self._send_telegram(config, f"*{title}*\n{message}", image_data=image_data)
|
|
|
elif provider_type == "email":
|
|
elif provider_type == "email":
|
|
|
- return await self._send_email(config, title, message)
|
|
|
|
|
|
|
+ return await self._send_email(config, title, message, image_data=image_data)
|
|
|
elif provider_type == "discord":
|
|
elif provider_type == "discord":
|
|
|
- return await self._send_discord(config, title, message)
|
|
|
|
|
|
|
+ return await self._send_discord(config, title, message, image_data=image_data)
|
|
|
elif provider_type == "webhook":
|
|
elif provider_type == "webhook":
|
|
|
- return await self._send_webhook(config, title, message)
|
|
|
|
|
|
|
+ photo_url = None
|
|
|
|
|
+ if attach_photo and (config.get("payload_format") or "generic").strip() == "slack":
|
|
|
|
|
+ photo_url = await self._get_or_build_photo_url(db, image_data, "test")
|
|
|
|
|
+ return await self._send_webhook(
|
|
|
|
|
+ config, title, message, image_data=image_data, event_type="test", image_url=photo_url
|
|
|
|
|
+ )
|
|
|
elif provider_type == "homeassistant":
|
|
elif provider_type == "homeassistant":
|
|
|
- return await self._send_homeassistant(config, title, message, db=db)
|
|
|
|
|
|
|
+ photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
|
|
|
|
|
+ return await self._send_homeassistant(config, title, message, db=db, image_url=photo_url)
|
|
|
elif provider_type == "bark":
|
|
elif provider_type == "bark":
|
|
|
- return await self._send_bark(config, title, message)
|
|
|
|
|
|
|
+ photo_url = await self._get_or_build_photo_url(db, image_data, "test") if attach_photo else None
|
|
|
|
|
+ return await self._send_bark(config, title, message, image_url=photo_url)
|
|
|
else:
|
|
else:
|
|
|
return False, f"Unknown provider type: {provider_type}"
|
|
return False, f"Unknown provider type: {provider_type}"
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
@@ -342,7 +395,9 @@ class NotificationService:
|
|
|
else:
|
|
else:
|
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
|
return False, f"HTTP {response.status_code}: {response.text[:200]}"
|
|
|
|
|
|
|
|
- async def _send_bark(self, config: dict, title: str, message: str, url: str | None = None) -> tuple[bool, str]:
|
|
|
|
|
|
|
+ async def _send_bark(
|
|
|
|
|
+ self, config: dict, title: str, message: str, url: str | None = None, image_url: str | None = None
|
|
|
|
|
+ ) -> tuple[bool, str]:
|
|
|
"""Send notification via Bark, the self-hostable iOS push service (#1495).
|
|
"""Send notification via Bark, the self-hostable iOS push service (#1495).
|
|
|
|
|
|
|
|
POSTs JSON to {server}/push. Defaults to the official api.day.app
|
|
POSTs JSON to {server}/push. Defaults to the official api.day.app
|
|
@@ -376,6 +431,10 @@ class NotificationService:
|
|
|
payload["level"] = level
|
|
payload["level"] = level
|
|
|
if url:
|
|
if url:
|
|
|
payload["url"] = url
|
|
payload["url"] = url
|
|
|
|
|
+ if image_url:
|
|
|
|
|
+ # Closest thing Bark has to a photo attachment — shows as a
|
|
|
|
|
+ # round icon on iOS.
|
|
|
|
|
+ payload["icon"] = image_url
|
|
|
|
|
|
|
|
client = await self._get_client()
|
|
client = await self._get_client()
|
|
|
response = await client.post(f"{server}/push", json=payload)
|
|
response = await client.post(f"{server}/push", json=payload)
|
|
@@ -926,6 +985,7 @@ class NotificationService:
|
|
|
image_data: bytes | None = None,
|
|
image_data: bytes | None = None,
|
|
|
event_type: str | None = None,
|
|
event_type: str | None = None,
|
|
|
variables: dict | None = None,
|
|
variables: dict | None = None,
|
|
|
|
|
+ image_url: str | None = None,
|
|
|
) -> tuple[bool, str]:
|
|
) -> tuple[bool, str]:
|
|
|
"""Send notification via generic webhook (POST JSON).
|
|
"""Send notification via generic webhook (POST JSON).
|
|
|
|
|
|
|
@@ -946,7 +1006,8 @@ class NotificationService:
|
|
|
|
|
|
|
|
# Build payload based on format
|
|
# Build payload based on format
|
|
|
if payload_format == "slack":
|
|
if payload_format == "slack":
|
|
|
- # Slack/Mattermost format - just text field
|
|
|
|
|
|
|
+ # Incoming webhooks can't take a byte upload, so a photo has to
|
|
|
|
|
+ # be a URL Slack/Mattermost fetch themselves.
|
|
|
data = {"text": f"*{title}*\n{message}"}
|
|
data = {"text": f"*{title}*\n{message}"}
|
|
|
if event_type == "print_confirm_request":
|
|
if event_type == "print_confirm_request":
|
|
|
# Slack and Mattermost fetch every URL in the text to build
|
|
# Slack and Mattermost fetch every URL in the text to build
|
|
@@ -963,6 +1024,8 @@ class NotificationService:
|
|
|
# get it back.
|
|
# get it back.
|
|
|
data["unfurl_links"] = False
|
|
data["unfurl_links"] = False
|
|
|
data["unfurl_media"] = False
|
|
data["unfurl_media"] = False
|
|
|
|
|
+ if image_url:
|
|
|
|
|
+ data["attachments"] = [{"fallback": title, "image_url": image_url}]
|
|
|
else:
|
|
else:
|
|
|
# Generic format with custom field names
|
|
# Generic format with custom field names
|
|
|
custom_field_title = config.get("field_title", "title").strip() or "title"
|
|
custom_field_title = config.get("field_title", "title").strip() or "title"
|
|
@@ -1009,7 +1072,12 @@ class NotificationService:
|
|
|
return False, f"Webhook error: {str(e)}"
|
|
return False, f"Webhook error: {str(e)}"
|
|
|
|
|
|
|
|
async def _send_homeassistant(
|
|
async def _send_homeassistant(
|
|
|
- self, config: dict, title: str, message: str, db: AsyncSession | None = None
|
|
|
|
|
|
|
+ self,
|
|
|
|
|
+ config: dict,
|
|
|
|
|
+ title: str,
|
|
|
|
|
+ message: str,
|
|
|
|
|
+ db: AsyncSession | None = None,
|
|
|
|
|
+ image_url: str | None = None,
|
|
|
) -> tuple[bool, str]:
|
|
) -> tuple[bool, str]:
|
|
|
"""Send notification via Home Assistant.
|
|
"""Send notification via Home Assistant.
|
|
|
|
|
|
|
@@ -1084,6 +1152,7 @@ class NotificationService:
|
|
|
# reach the notify service. Only included when configured — the default
|
|
# reach the notify service. Only included when configured — the default
|
|
|
# persistent_notification.create schema rejects unknown keys.
|
|
# persistent_notification.create schema rejects unknown keys.
|
|
|
raw_data = config.get("data")
|
|
raw_data = config.get("data")
|
|
|
|
|
+ data_payload: dict = {}
|
|
|
if raw_data:
|
|
if raw_data:
|
|
|
if isinstance(raw_data, str):
|
|
if isinstance(raw_data, str):
|
|
|
try:
|
|
try:
|
|
@@ -1094,8 +1163,19 @@ class NotificationService:
|
|
|
parsed_data = raw_data
|
|
parsed_data = raw_data
|
|
|
if not isinstance(parsed_data, dict):
|
|
if not isinstance(parsed_data, dict):
|
|
|
return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
|
|
return False, 'The Data field must be a JSON object, e.g. {"priority": "high", "ttl": 0}'
|
|
|
- if parsed_data:
|
|
|
|
|
- payload["data"] = parsed_data
|
|
|
|
|
|
|
+ data_payload = parsed_data
|
|
|
|
|
+
|
|
|
|
|
+ if image_url and service:
|
|
|
|
|
+ # HA's notify.* services fetch data.image themselves. Gated on a
|
|
|
|
|
+ # custom service being set: the default persistent_notification
|
|
|
|
|
+ # .create has a strict schema and 400s on fields it doesn't
|
|
|
|
|
+ # recognize, so we'd break plain notifications for anyone who
|
|
|
|
|
+ # never asked for a photo. setdefault so a user's own "image"
|
|
|
|
|
+ # key still wins.
|
|
|
|
|
+ data_payload.setdefault("image", image_url)
|
|
|
|
|
+
|
|
|
|
|
+ if data_payload:
|
|
|
|
|
+ payload["data"] = data_payload
|
|
|
|
|
|
|
|
client = await self._get_client()
|
|
client = await self._get_client()
|
|
|
response = await client.post(url, json=payload, headers=headers)
|
|
response = await client.post(url, json=payload, headers=headers)
|
|
@@ -1111,6 +1191,54 @@ class NotificationService:
|
|
|
# gets the same treatment.
|
|
# gets the same treatment.
|
|
|
return False, _opaque_http_failure(response, label="Home Assistant endpoint")
|
|
return False, _opaque_http_failure(response, label="Home Assistant endpoint")
|
|
|
|
|
|
|
|
|
|
+ async def _get_or_build_photo_url(
|
|
|
|
|
+ self,
|
|
|
|
|
+ db: AsyncSession | None,
|
|
|
|
|
+ image_data: bytes | None,
|
|
|
|
|
+ event_type: str | None,
|
|
|
|
|
+ photo_cache: dict | None = None,
|
|
|
|
|
+ ) -> str | None:
|
|
|
|
|
+ """Return a URL for a notification photo, for providers that fetch it themselves.
|
|
|
|
|
+
|
|
|
|
|
+ Writes *image_data* under an unguessable filename and builds the URL
|
|
|
|
|
+ from the ``external_url`` setting. The filename is the only credential
|
|
|
|
|
+ (see notification_photos.py), so the URL opens this one photo and
|
|
|
|
|
+ nothing else -- no camera token, which would open every printer's live
|
|
|
|
|
+ stream. Print Complete goes through here too rather than reusing the
|
|
|
|
|
+ archive's ``finish_photo_url``: that route needs a media token, so with
|
|
|
|
|
+ auth on HA/Bark/Slack would get a 401.
|
|
|
|
|
+
|
|
|
|
|
+ Returns None with no photo or no ``external_url`` set: HA and Bark
|
|
|
|
|
+ fetch this URL themselves, so a relative path does them no good.
|
|
|
|
|
+
|
|
|
|
|
+ *photo_cache* is shared by every provider in one send, so only the
|
|
|
|
|
+ first HA/Bark/Slack provider pays for the disk write. It's kept apart
|
|
|
|
|
+ from the template variables so the URL never lands in a generic
|
|
|
|
|
+ webhook's payload.
|
|
|
|
|
+ """
|
|
|
|
|
+ if photo_cache is not None and "url" in photo_cache:
|
|
|
|
|
+ return photo_cache["url"]
|
|
|
|
|
+
|
|
|
|
|
+ if not image_data or db is None:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ from backend.app.api.routes.settings import get_setting
|
|
|
|
|
+
|
|
|
|
|
+ external_url = (await get_setting(db, "external_url") or "").strip()
|
|
|
|
|
+ if not external_url:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ try:
|
|
|
|
|
+ filename = await asyncio.to_thread(save_notification_photo, image_data, event_type or "event")
|
|
|
|
|
+ except Exception as e:
|
|
|
|
|
+ logger.warning("Failed to persist notification photo: %s", e)
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ url = f"{external_url.rstrip('/')}/api/v1/notifications/photos/{filename}"
|
|
|
|
|
+ if photo_cache is not None:
|
|
|
|
|
+ photo_cache["url"] = url
|
|
|
|
|
+ return url
|
|
|
|
|
+
|
|
|
async def _send_to_provider(
|
|
async def _send_to_provider(
|
|
|
self,
|
|
self,
|
|
|
provider: NotificationProvider,
|
|
provider: NotificationProvider,
|
|
@@ -1120,8 +1248,13 @@ class NotificationService:
|
|
|
image_data: bytes | None = None,
|
|
image_data: bytes | None = None,
|
|
|
event_type: str | None = None,
|
|
event_type: str | None = None,
|
|
|
variables: dict | None = None,
|
|
variables: dict | None = None,
|
|
|
|
|
+ photo_cache: dict | None = None,
|
|
|
) -> tuple[bool, str]:
|
|
) -> tuple[bool, str]:
|
|
|
- """Send notification to a specific provider."""
|
|
|
|
|
|
|
+ """Send notification to a specific provider.
|
|
|
|
|
+
|
|
|
|
|
+ *photo_cache* lets the providers of one send share a single persisted
|
|
|
|
|
+ photo URL (see _get_or_build_photo_url).
|
|
|
|
|
+ """
|
|
|
# Check quiet hours
|
|
# Check quiet hours
|
|
|
if self._is_in_quiet_hours(provider):
|
|
if self._is_in_quiet_hours(provider):
|
|
|
logger.info("Skipping notification to %s - quiet hours active", provider.name)
|
|
logger.info("Skipping notification to %s - quiet hours active", provider.name)
|
|
@@ -1129,6 +1262,13 @@ class NotificationService:
|
|
|
|
|
|
|
|
config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
|
|
config = json.loads(provider.config) if isinstance(provider.config, str) else provider.config
|
|
|
|
|
|
|
|
|
|
+ # attach_photo is a per-provider opt-out. Clearing image_data covers
|
|
|
|
|
+ # the byte-upload providers below; HA/Bark/Slack get their own check
|
|
|
|
|
+ # further down too, since they could otherwise get the URL an earlier
|
|
|
|
|
+ # provider in the same send left in photo_cache.
|
|
|
|
|
+ if not provider.attach_photo:
|
|
|
|
|
+ image_data = None
|
|
|
|
|
+
|
|
|
try:
|
|
try:
|
|
|
if provider.provider_type == "callmebot":
|
|
if provider.provider_type == "callmebot":
|
|
|
return await self._send_callmebot(config, f"{title}\n{message}")
|
|
return await self._send_callmebot(config, f"{title}\n{message}")
|
|
@@ -1222,11 +1362,27 @@ class NotificationService:
|
|
|
elif provider.provider_type == "discord":
|
|
elif provider.provider_type == "discord":
|
|
|
return await self._send_discord(config, title, message, image_data=image_data)
|
|
return await self._send_discord(config, title, message, image_data=image_data)
|
|
|
elif provider.provider_type == "webhook":
|
|
elif provider.provider_type == "webhook":
|
|
|
|
|
+ # Only Slack format needs a URL — generic format gets the
|
|
|
|
|
+ # base64 image field instead.
|
|
|
|
|
+ photo_url = None
|
|
|
|
|
+ if provider.attach_photo and (config.get("payload_format") or "generic").strip() == "slack":
|
|
|
|
|
+ photo_url = await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
|
|
|
return await self._send_webhook(
|
|
return await self._send_webhook(
|
|
|
- config, title, message, image_data=image_data, event_type=event_type, variables=variables
|
|
|
|
|
|
|
+ config,
|
|
|
|
|
+ title,
|
|
|
|
|
+ message,
|
|
|
|
|
+ image_data=image_data,
|
|
|
|
|
+ event_type=event_type,
|
|
|
|
|
+ variables=variables,
|
|
|
|
|
+ image_url=photo_url,
|
|
|
)
|
|
)
|
|
|
elif provider.provider_type == "homeassistant":
|
|
elif provider.provider_type == "homeassistant":
|
|
|
- return await self._send_homeassistant(config, title, message, db=db)
|
|
|
|
|
|
|
+ photo_url = (
|
|
|
|
|
+ await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
|
|
|
|
|
+ if provider.attach_photo
|
|
|
|
|
+ else None
|
|
|
|
|
+ )
|
|
|
|
|
+ return await self._send_homeassistant(config, title, message, db=db, image_url=photo_url)
|
|
|
elif provider.provider_type == "bark":
|
|
elif provider.provider_type == "bark":
|
|
|
# Outcome confirmation (#1898): Bark opens one URL on tap —
|
|
# Outcome confirmation (#1898): Bark opens one URL on tap —
|
|
|
# deep-link into the confirmation dialog, like Pushover.
|
|
# deep-link into the confirmation dialog, like Pushover.
|
|
@@ -1234,7 +1390,12 @@ class NotificationService:
|
|
|
_bark_confirm = (variables or {}).get("confirm_url")
|
|
_bark_confirm = (variables or {}).get("confirm_url")
|
|
|
if event_type == "print_confirm_request" and _bark_confirm and _bark_confirm.startswith("http"):
|
|
if event_type == "print_confirm_request" and _bark_confirm and _bark_confirm.startswith("http"):
|
|
|
bark_url = _bark_confirm
|
|
bark_url = _bark_confirm
|
|
|
- return await self._send_bark(config, title, message, url=bark_url)
|
|
|
|
|
|
|
+ photo_url = (
|
|
|
|
|
+ await self._get_or_build_photo_url(db, image_data, event_type, photo_cache)
|
|
|
|
|
+ if provider.attach_photo
|
|
|
|
|
+ else None
|
|
|
|
|
+ )
|
|
|
|
|
+ return await self._send_bark(config, title, message, url=bark_url, image_url=photo_url)
|
|
|
else:
|
|
else:
|
|
|
return False, f"Unknown provider type: {provider.provider_type}"
|
|
return False, f"Unknown provider type: {provider.provider_type}"
|
|
|
except Exception as e:
|
|
except Exception as e:
|
|
@@ -1337,11 +1498,19 @@ class NotificationService:
|
|
|
All notifications are always sent immediately. If digest mode is enabled,
|
|
All notifications are always sent immediately. If digest mode is enabled,
|
|
|
the notification is ALSO queued for the daily digest summary.
|
|
the notification is ALSO queued for the daily digest summary.
|
|
|
"""
|
|
"""
|
|
|
|
|
+ photo_cache: dict = {}
|
|
|
for provider in providers:
|
|
for provider in providers:
|
|
|
try:
|
|
try:
|
|
|
# Always send notification immediately
|
|
# Always send notification immediately
|
|
|
success, error = await self._send_to_provider(
|
|
success, error = await self._send_to_provider(
|
|
|
- provider, title, message, db, image_data=image_data, event_type=event_type, variables=variables
|
|
|
|
|
|
|
+ provider,
|
|
|
|
|
+ title,
|
|
|
|
|
+ message,
|
|
|
|
|
+ db,
|
|
|
|
|
+ image_data=image_data,
|
|
|
|
|
+ event_type=event_type,
|
|
|
|
|
+ variables=variables,
|
|
|
|
|
+ photo_cache=photo_cache,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
# Also queue for digest if enabled (digest is a summary, not a queue)
|
|
# Also queue for digest if enabled (digest is a summary, not a queue)
|
|
@@ -1864,6 +2033,7 @@ class NotificationService:
|
|
|
printer_name: str,
|
|
printer_name: str,
|
|
|
db: AsyncSession,
|
|
db: AsyncSession,
|
|
|
difference_percent: float | None = None,
|
|
difference_percent: float | None = None,
|
|
|
|
|
+ image_data: bytes | None = None,
|
|
|
):
|
|
):
|
|
|
"""Handle plate not empty event - objects detected on build plate before print."""
|
|
"""Handle plate not empty event - objects detected on build plate before print."""
|
|
|
providers = await self._get_providers_for_event(db, "on_plate_not_empty", printer_id)
|
|
providers = await self._get_providers_for_event(db, "on_plate_not_empty", printer_id)
|
|
@@ -1885,6 +2055,7 @@ class NotificationService:
|
|
|
printer_id,
|
|
printer_id,
|
|
|
printer_name,
|
|
printer_name,
|
|
|
force_immediate=True,
|
|
force_immediate=True,
|
|
|
|
|
+ image_data=image_data,
|
|
|
variables=variables,
|
|
variables=variables,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -2309,6 +2480,8 @@ class NotificationService:
|
|
|
printer_name: str,
|
|
printer_name: str,
|
|
|
filename: str,
|
|
filename: str,
|
|
|
db: AsyncSession,
|
|
db: AsyncSession,
|
|
|
|
|
+ image_data: bytes | None = None,
|
|
|
|
|
+ finish_photo_url: str | None = None,
|
|
|
) -> None:
|
|
) -> None:
|
|
|
"""Send a print event email notification to the user who submitted the job.
|
|
"""Send a print event email notification to the user who submitted the job.
|
|
|
|
|
|
|
@@ -2318,6 +2491,10 @@ class NotificationService:
|
|
|
printer_name: Name of the printer
|
|
printer_name: Name of the printer
|
|
|
filename: Raw filename or subtask name
|
|
filename: Raw filename or subtask name
|
|
|
db: Database session
|
|
db: Database session
|
|
|
|
|
+ image_data: Camera snapshot bytes, if one was captured for the event.
|
|
|
|
|
+ finish_photo_url: The same snapshot's public URL — only used so the
|
|
|
|
|
+ template can reference {finish_photo_url}, which is what opts
|
|
|
|
|
+ the email into inlining the photo (see send_user_print_notification).
|
|
|
"""
|
|
"""
|
|
|
if created_by_id is None:
|
|
if created_by_id is None:
|
|
|
logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
|
|
logger.debug("[EMAIL] Skipping user print email (%s): no created_by_id", event_type)
|
|
@@ -2403,6 +2580,8 @@ class NotificationService:
|
|
|
"printer": printer_name,
|
|
"printer": printer_name,
|
|
|
"filename": self._clean_filename(filename),
|
|
"filename": self._clean_filename(filename),
|
|
|
}
|
|
}
|
|
|
|
|
+ if finish_photo_url:
|
|
|
|
|
+ variables["finish_photo_url"] = finish_photo_url
|
|
|
|
|
|
|
|
# Send the email
|
|
# Send the email
|
|
|
await send_user_print_notification(
|
|
await send_user_print_notification(
|
|
@@ -2411,6 +2590,7 @@ class NotificationService:
|
|
|
user_email=user.email,
|
|
user_email=user.email,
|
|
|
username=user.username,
|
|
username=user.username,
|
|
|
variables=variables,
|
|
variables=variables,
|
|
|
|
|
+ image_data=image_data,
|
|
|
)
|
|
)
|
|
|
logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
|
|
logger.info("[EMAIL] User print email sent: event=%s → %s", event_type, user.email)
|
|
|
except Exception as e:
|
|
except Exception as e:
|