238 lines
9.3 KiB
Python
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)
|