infrasynth-backend-kit/infrasynth/notifications/services.py
jcv-dev 551b42eab5 feat: production-hardening pass across the kit
Close the gaps between the documented contract (API-STANDARD, TENANCY,
ENTITLEMENTS) and the implementation, and remove committed build artifacts.

Security:
- verify + process inbound webhooks (HMAC/handler verify, size limit,
  timestamp tolerance, idempotency via InboundEvent.external_id)
- real 2FA login flow (pre-auth challenge; tokens only after verify/recovery)
- wire HybridPermission into security/audit views; add API-key rotate and
  users/<id>/permissions|roles endpoints
- tenant-scoped throttling on by default; webhook replay protection
- verify MercadoPago webhook signatures
- login brute-force guard, configurable password policy, real ALTCHA PoW

Correctness:
- apply verified billing webhooks idempotently (subscription/entitlement/
  invoice/PaymentTransaction); scheduled payment lifecycle jobs
- capture audit update diffs automatically; add audit retention purge
- working notification retries, per-channel rate limits, log retention
- pluggable virus scanner, upload-size limit, pipeline toggle
- feature rollout %/environment targeting; settings-driven registrations
- workflow guards (instance cap, route depth, self-assignment, clone on re-entry)
- wire every previously-dead INFRASYNTH_* setting; drop truly dead ones

Delivery:
- README + CHANGELOG; CI format check + coverage gate
- keep test media out of the tree; untrack .coverage, __pycache__,
  egg-info, docs/ and invoice artifacts
2026-09-24 10:41:21 -05:00

279 lines
11 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, 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)