infrasynth-backend-kit/infrasynth/notifications/services.py
2026-08-28 14:38:47 -05:00

238 lines
9.3 KiB
Python

import logging
from datetime import timedelta
from django.template import Context, Template
from django.utils import timezone
from django.utils.module_loading import import_string
from infrasynth.shared.results import Result
from infrasynth.shared.settings_utils import get_setting
from .models import ChannelConfig, NotificationDispatch, NotificationTemplate
from .resolvers import VariableResolverRegistry
from .signals import notification_failed, notification_sent
logger = logging.getLogger(__name__)
class NotificationService:
"""Public API for sending notifications through configured channels."""
def send(
self,
recipient: str,
subject: str,
body: str,
channel: str,
template: NotificationTemplate | None = None,
context: dict | None = None,
request_id: str | None = None,
) -> NotificationDispatch:
"""Sends a notification synchronously or asynchronously per configuration."""
context = context or {}
rendered_subject, rendered_body = self._render(template, subject, body, context)
dispatch = NotificationDispatch.objects.create(
template=template,
recipient=recipient,
channel=channel,
subject=rendered_subject,
body=rendered_body,
status=NotificationDispatch.Status.PENDING,
context_snapshot=self._snapshot_context(context),
request_id=request_id or None,
)
backend = get_setting("INFRASYNTH_NOTIFICATIONS", "DISPATCH_BACKEND", "celery")
if backend == "sync":
self._execute_send(dispatch)
elif backend == "thread":
import threading
threading.Thread(target=self._execute_send, args=(dispatch,), daemon=True).start()
else:
from .tasks import deliver_notification
deliver_notification.delay(dispatch.id)
return dispatch
def send_with_failover(
self,
recipient: str,
subject: str,
body: str,
channels: list[str],
template: NotificationTemplate | None = None,
context: dict | None = None,
request_id: str | None = None,
) -> NotificationDispatch:
"""Sends with automatic failover. If the primary channel fails, tries the next."""
context = context or {}
rendered_subject, rendered_body = self._render(template, subject, body, context)
dispatch = NotificationDispatch.objects.create(
template=template,
recipient=recipient,
channel=channels[0] if channels else "",
subject=rendered_subject,
body=rendered_body,
status=NotificationDispatch.Status.PENDING,
context_snapshot=self._snapshot_context(context),
request_id=request_id or None,
)
backend = get_setting("INFRASYNTH_NOTIFICATIONS", "DISPATCH_BACKEND", "celery")
if backend == "sync":
self._execute_failover(dispatch, channels)
else:
from .tasks import deliver_notification_with_failover
deliver_notification_with_failover.delay(dispatch.id, channels)
return dispatch
def get_template(self, slug: str, namespace: str | None = None) -> NotificationTemplate | None:
"""Retrieves a template by slug (and optional namespace)."""
qs = NotificationTemplate.objects.filter(slug=slug)
if namespace is not None:
qs = qs.filter(namespace=namespace)
else:
qs = qs.filter(namespace="")
return qs.first()
def _render(
self,
template: NotificationTemplate | None,
subject: str,
body: str,
context: dict,
) -> tuple[str, str]:
render_context = self._build_render_context(template, context)
if not template:
return subject, body
subject_template = Template(template.subject_template or subject)
body_template = Template(template.body_template or body)
return (
subject_template.render(Context(render_context)),
body_template.render(Context(render_context)),
)
def _build_render_context(self, template, context: dict) -> dict:
render_context = dict(context)
namespace = template.namespace if template else None
if namespace:
variables = VariableResolverRegistry.get_available_variables(namespace)
for key, entry in variables.items():
var_name = entry.get("variable_name")
if var_name and var_name not in render_context:
value = VariableResolverRegistry.resolve(var_name, "", context, namespace=namespace)
render_context[var_name] = value
return render_context
def _snapshot_context(self, context: dict) -> dict:
snapshot = {}
for key, value in context.items():
try:
import json
json.dumps(value)
snapshot[key] = value
except (TypeError, ValueError):
snapshot[key] = str(value)
return snapshot
def _execute_send(self, dispatch: NotificationDispatch) -> None:
channel_backend = self._get_channel_backend(dispatch.channel)
if channel_backend is None:
self._mark_failed(dispatch, f"No channel backend configured for '{dispatch.channel}'")
return
is_html = bool(dispatch.template and dispatch.template.is_html)
result = channel_backend.send(dispatch.recipient, dispatch.subject, dispatch.body, is_html=is_html)
self._finalize(dispatch, result)
def _execute_failover(self, dispatch: NotificationDispatch, channels: list[str]) -> None:
max_retries = int(get_setting("INFRASYNTH_NOTIFICATIONS", "MAX_RETRIES", 3))
is_html = bool(dispatch.template and dispatch.template.is_html)
last_error = "No channels configured"
last_channel = dispatch.channel
for channel_name in channels:
last_channel = channel_name
channel_backend = self._get_channel_backend(channel_name)
if channel_backend is None:
last_error = f"No channel backend configured for '{channel_name}'"
continue
result = channel_backend.send(dispatch.recipient, dispatch.subject, dispatch.body, is_html=is_html)
if result.is_ok:
dispatch.channel = channel_name
dispatch.save(update_fields=["channel"])
self._finalize(dispatch, Result.ok(True))
return
last_error = result.error or "Unknown error"
dispatch.attempt += 1
dispatch.save(update_fields=["attempt"])
dispatch.channel = last_channel
self._mark_failed(dispatch, last_error, max_retries)
def _get_channel_backend(self, channel_type: str):
channels_config = get_setting("INFRASYNTH_NOTIFICATIONS", "CHANNELS", {})
channel_config = channels_config.get(channel_type)
if not channel_config:
return None
primary_path = channel_config.get("primary")
if not primary_path:
return None
try:
channel_cls = import_string(primary_path)
except ImportError:
logger.warning("Could not import channel backend '%s'", primary_path)
return None
db_config = ChannelConfig.objects.filter(slug=channel_type, is_active=True).first()
config = db_config.config if db_config else {}
return channel_cls.from_config(config)
def _finalize(self, dispatch: NotificationDispatch, result: Result) -> None:
if result.is_ok:
dispatch.status = NotificationDispatch.Status.SENT
dispatch.completed_at = timezone.now()
dispatch.error_message = ""
dispatch.save(update_fields=["status", "completed_at", "error_message"])
notification_sent.send(
sender=NotificationDispatch,
dispatch_id=dispatch.id,
recipient=dispatch.recipient,
channel=dispatch.channel,
status=dispatch.status,
)
else:
self._mark_failed(dispatch, result.error or "Unknown error")
def _mark_failed(self, dispatch: NotificationDispatch, error: str, max_retries: int = 3) -> None:
if dispatch.attempt < max_retries:
dispatch.status = NotificationDispatch.Status.RETRYING
delays = get_setting("INFRASYNTH_NOTIFICATIONS", "RETRY_DELAY_SECONDS", [60, 300, 900])
delay = delays[min(dispatch.attempt, len(delays) - 1)] if delays else 60
dispatch.next_retry_at = timezone.now() + timedelta(seconds=delay)
else:
dispatch.status = NotificationDispatch.Status.FAILED
dispatch.completed_at = timezone.now()
dispatch.error_message = error
dispatch.save()
if dispatch.status == NotificationDispatch.Status.FAILED:
notification_failed.send(
sender=NotificationDispatch,
dispatch_id=dispatch.id,
recipient=dispatch.recipient,
channel=dispatch.channel,
error=error,
)
def send_notification_direct(dispatch_id: int) -> None:
dispatch = NotificationDispatch.objects.select_related("template").get(pk=dispatch_id)
NotificationService()._execute_send(dispatch)