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, str(dispatch.tenant_id) if dispatch.tenant_id else None) 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, str(dispatch.tenant_id) if dispatch.tenant_id else None ) 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: if self._rate_limited(dispatch): self._mark_failed(dispatch, f"Rate limit exceeded for channel '{dispatch.channel}'") return 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 _rate_limited(self, dispatch: NotificationDispatch) -> bool: """Fixed-window per-channel, per-tenant rate limit (``RATE_LIMIT_PER_CHANNEL``).""" rates = get_setting("INFRASYNTH_NOTIFICATIONS", "RATE_LIMIT_PER_CHANNEL", {}) or {} rate = rates.get(dispatch.channel) if not rate: return False try: count_part, period = str(rate).split("/", 1) limit = int(count_part) window = {"s": 1, "m": 60, "h": 3600, "d": 86400}.get(period.strip().lower(), 60) except (ValueError, AttributeError): return False from django.core.cache import cache key = f"tenant:{dispatch.tenant_id or 'global'}:notif:rate:{dispatch.channel}" try: count = cache.incr(key) except ValueError: cache.set(key, 1, window) count = 1 return count > limit 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, increment=False) 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, ) self._maybe_drop_log(dispatch) else: self._mark_failed(dispatch, result.error or "Unknown error") def _mark_failed( self, dispatch: NotificationDispatch, error: str, max_retries: int = 3, *, increment: bool = True ) -> None: if increment: dispatch.attempt += 1 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 - 1, 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, ) self._maybe_drop_log(dispatch) @staticmethod def _maybe_drop_log(dispatch: NotificationDispatch) -> None: """``STORE_DISPATCH_LOGS=False`` keeps no delivery history.""" if get_setting("INFRASYNTH_NOTIFICATIONS", "STORE_DISPATCH_LOGS", True): return if dispatch.status in (NotificationDispatch.Status.SENT, NotificationDispatch.Status.FAILED): dispatch.delete() def send_notification_direct(dispatch_id: int) -> None: dispatch = NotificationDispatch.objects.select_related("template").get(pk=dispatch_id) NotificationService()._execute_send(dispatch)