Python SDK

Django Integration

Complete Django integration with AppConfig initialization, forms, class-based views, middleware, signals, and Celery support.

This example follows Django 5.0 best practices including proper AppConfig, class-based views, and async-ready patterns.

Project Structure

myproject/
├── myproject/
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── sent_integration/
│   ├── __init__.py
│   ├── apps.py              # AppConfig with initialization
│   ├── admin.py             # Admin integration
│   ├── models.py            # Custom model fields
│   ├── forms.py             # Forms with validation
│   ├── views.py             # Class-based views
│   ├── urls.py              # URL patterns
│   ├── middleware.py        # Request logging middleware
│   ├── signals.py           # Message event signals
│   ├── handlers.py          # Signal handlers
│   ├── tasks.py             # Celery tasks
│   ├── serializers.py       # DRF serializers
│   ├── api_views.py         # DRF viewsets
│   └── tests/               # Test files
└── manage.py

Configuration

settings.py

import os
from pathlib import Path

BASE_DIR = Path(__file__).resolve().parent.parent

# Sent DM Configuration
SENT_DM_API_KEY = os.environ.get("SENT_DM_API_KEY")
SENT_DM_WEBHOOK_SECRET = os.environ.get("SENT_DM_WEBHOOK_SECRET")
SENT_DM_WEBHOOK_PATH = os.environ.get("SENT_DM_WEBHOOK_PATH", "/webhooks/sent/")
SENT_DM_MAX_RETRIES = int(os.environ.get("SENT_DM_MAX_RETRIES", "3"))
SENT_DM_RETRY_DELAY = int(os.environ.get("SENT_DM_RETRY_DELAY", "60"))

if not SENT_DM_API_KEY:
    raise ValueError("SENT_DM_API_KEY environment variable is required.")

INSTALLED_APPS = [
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    "rest_framework",
    "celery",
    "sent_integration.apps.SentIntegrationConfig",
]

REST_FRAMEWORK = {
    "DEFAULT_AUTHENTICATION_CLASSES": [
        "rest_framework.authentication.SessionAuthentication",
        "rest_framework.authentication.TokenAuthentication",
    ],
    "DEFAULT_PERMISSION_CLASSES": [
        "rest_framework.permissions.IsAuthenticated",
    ],
    "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination",
    "PAGE_SIZE": 20,
}

CELERY_BROKER_URL = os.environ.get("CELERY_BROKER_URL", "redis://localhost:6379/0")
CELERY_RESULT_BACKEND = os.environ.get("CELERY_RESULT_BACKEND", "redis://localhost:6379/0")

Environment Variables

SENT_DM_API_KEY=your_api_key_here
SENT_DM_WEBHOOK_SECRET=your_webhook_secret_here
SENT_DM_WEBHOOK_PATH=/webhooks/sent/
SENT_DM_MAX_RETRIES=3
SENT_DM_RETRY_DELAY=60
CELERY_BROKER_URL=redis://localhost:6379/0
CELERY_RESULT_BACKEND=redis://localhost:6379/0

Environment Variables Reference

VariableRequiredDefaultDescription
SENT_DM_API_KEYYes-Your Sent DM API key
SENT_DM_WEBHOOK_SECRETNo-Secret for webhook signature verification
SENT_DM_WEBHOOK_PATHNo/webhooks/sent/URL path for webhook endpoint
SENT_DM_MAX_RETRIESNo3Maximum retries for failed API calls
SENT_DM_RETRY_DELAYNo60Delay in seconds between retries
CELERY_BROKER_URLNoredis://localhost:6379/0Celery broker URL
CELERY_RESULT_BACKENDNoredis://localhost:6379/0Celery result backend URL

AppConfig with Initialization

apps.py

from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured


class SentIntegrationConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "sent_integration"
    verbose_name = "Sent DM Integration"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._sent_client = None

    @property
    def sent_client(self):
        if self._sent_client is None:
            self._sent_client = self._create_client()
        return self._sent_client

    def _create_client(self):
        from sent_dm import SentDm
        api_key = getattr(settings, "SENT_DM_API_KEY", None)
        if not api_key:
            raise ImproperlyConfigured("SENT_DM_API_KEY must be set in settings")
        return SentDm(api_key)

    def ready(self):
        import sent_integration.handlers  # noqa: F401
        self._validate_configuration()

    def _validate_configuration(self):
        webhook_secret = getattr(settings, "SENT_DM_WEBHOOK_SECRET", None)
        if not webhook_secret:
            import warnings
            warnings.warn(
                "SENT_DM_WEBHOOK_SECRET is not set. Webhook verification will fail.",
                RuntimeWarning,
                stacklevel=2,
            )


def get_sent_client():
    from django.apps import apps
    config = apps.get_app_config("sent_integration")
    return config.sent_client

Models

models.py

from django.db import models
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
import re


class PhoneNumberField(models.CharField):
    E164_REGEX = re.compile(r"^\+[1-9]\d{1,14}$")

    def __init__(self, *args, **kwargs):
        kwargs.setdefault("max_length", 20)
        kwargs.setdefault("help_text", _("Phone number in E.164 format (e.g., +1234567890)"))
        super().__init__(*args, **kwargs)

    def validate(self, value, model_instance):
        super().validate(value, model_instance)
        if value and not self.E164_REGEX.match(value):
            raise ValidationError(
                _("Enter a valid phone number in E.164 format (e.g., +1234567890)."),
                code="invalid_phone_number"
            )


class SentMessage(models.Model):
    id = models.BigAutoField(primary_key=True)
    sent_id = models.CharField(max_length=100, unique=True, db_index=True, verbose_name=_("Sent Message ID"))
    phone_number = PhoneNumberField(verbose_name=_("Phone Number"))
    template_id = models.CharField(max_length=100, verbose_name=_("Template ID"))
    template_name = models.CharField(max_length=100, verbose_name=_("Template Name"))
    variables = models.JSONField(default=dict, blank=True, verbose_name=_("Template Variables"))
    status = models.CharField(max_length=20, default="pending", verbose_name=_("Status"))
    channels = models.JSONField(default=list, blank=True, verbose_name=_("Channels"))
    error_message = models.TextField(blank=True, verbose_name=_("Error Message"))
    sent_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Sent At"))
    delivered_at = models.DateTimeField(null=True, blank=True, verbose_name=_("Delivered At"))
    created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created At"))
    updated_at = models.DateTimeField(auto_now=True, verbose_name=_("Updated At"))

    class Meta:
        verbose_name = _("Sent Message")
        verbose_name_plural = _("Sent Messages")
        ordering = ["-created_at"]
        indexes = [
            models.Index(fields=["status", "created_at"]),
            models.Index(fields=["phone_number", "created_at"]),
        ]

    def __str__(self):
        return f"{self.template_name} to {self.phone_number} ({self.status})"

    def mark_as_sent(self, sent_id: str):
        from django.utils import timezone
        self.sent_id = sent_id
        self.status = "sent"
        self.sent_at = timezone.now()
        self.save(update_fields=["sent_id", "status", "sent_at"])

    def mark_as_delivered(self):
        from django.utils import timezone
        self.status = "delivered"
        self.delivered_at = timezone.now()
        self.save(update_fields=["status", "delivered_at"])

    def mark_as_failed(self, error_message: str):
        self.status = "failed"
        self.error_message = error_message
        self.save(update_fields=["status", "error_message"])


class MessageTemplate(models.Model):
    template_id = models.CharField(max_length=100, unique=True, db_index=True)
    name = models.CharField(max_length=100)
    description = models.TextField(blank=True)
    content = models.TextField()
    variables = models.JSONField(default=list)
    channels = models.JSONField(default=list)
    is_active = models.BooleanField(default=True)
    last_synced = models.DateTimeField(auto_now=True)

    class Meta:
        ordering = ["name"]

    def __str__(self):
        return self.name

Forms (Condensed)

forms.py

import re
from django import forms
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _


class SendMessageForm(forms.Form):
    phone_number = forms.CharField(max_length=20, label=_("Phone Number"))
    template_id = forms.CharField(max_length=100, label=_("Template ID"))
    template_name = forms.CharField(max_length=100, label=_("Template Name"))
    variables = forms.JSONField(required=False, initial=dict, label=_("Template Variables"))
    channels = forms.CharField(required=False, label=_("Channels"))

    def clean_phone_number(self):
        phone = self.cleaned_data.get("phone_number", "").strip()
        phone = re.sub(r"\s+", "", phone)
        e164_pattern = re.compile(r"^\+[1-9]\d{1,14}$")
        if not e164_pattern.match(phone):
            raise ValidationError(_("Enter a valid phone number in E.164 format."))
        return phone

    def clean_channels(self):
        channels_str = self.cleaned_data.get("channels", "").strip()
        if not channels_str:
            return ["whatsapp"]
        channels = [c.strip().lower() for c in channels_str.split(",")]
        valid_channels = {"whatsapp", "sms", "email", "push"}
        invalid = set(channels) - valid_channels
        if invalid:
            raise ValidationError(_("Invalid channels: %(channels)s"), params={"channels": ", ".join(invalid)})
        return channels


class WelcomeMessageForm(forms.Form):
    phone_number = forms.CharField(max_length=20, label=_("Phone Number"))
    name = forms.CharField(max_length=100, required=False, label=_("Customer Name"))

    def clean_phone_number(self):
        phone = self.cleaned_data.get("phone_number", "").strip()
        phone = re.sub(r"\s+", "", phone)
        if not re.compile(r"^\+[1-9]\d{1,14}$").match(phone):
            raise ValidationError(_("Enter a valid phone number in E.164 format."))
        return phone


# Additional forms: OrderConfirmationForm, BulkMessageForm...
# See full source for complete implementations

Signals

signals.py

from django.dispatch import Signal

message_queued = Signal()      # Sent when a message is queued
message_sent = Signal()        # Sent when a message is successfully sent
message_delivered = Signal()   # Sent when a message is delivered
message_failed = Signal()      # Sent when a message fails
webhook_received = Signal()    # Sent when a webhook is received

handlers.py

import logging
from django.dispatch import receiver
from .signals import message_sent, message_delivered, message_failed

logger = logging.getLogger(__name__)


@receiver(message_sent)
def log_message_sent(sender, instance, response, **kwargs):
    logger.info("Message sent: %s (Sent ID: %s)", instance.id, instance.sent_id)


@receiver(message_delivered)
def log_message_delivered(sender, instance, event_data, **kwargs):
    logger.info("Message delivered: %s (Sent ID: %s)", instance.id, instance.sent_id)


@receiver(message_failed)
def handle_message_failed(sender, instance, error, **kwargs):
    logger.error("Message failed: %s - Error: %s", instance.id, error)
    # Add retry logic or alert notifications here

# Additional handlers for message_queued, message_read, webhook_received...
# See full source for complete implementations

Class-Based Views

views.py

import json
import logging
from django.http import JsonResponse
from django.views import View
from django.views.generic import FormView, TemplateView
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.contrib.auth.mixins import LoginRequiredMixin, PermissionRequiredMixin
from django.urls import reverse_lazy
from django.contrib import messages
from django.conf import settings

from .forms import SendMessageForm, WelcomeMessageForm
from .models import SentMessage
from .apps import get_sent_client
from .signals import message_queued, message_sent, message_delivered, message_failed

logger = logging.getLogger(__name__)


class SendMessageView(LoginRequiredMixin, PermissionRequiredMixin, FormView):
    template_name = "sent_integration/send_message.html"
    form_class = SendMessageForm
    permission_required = "sent_integration.send_message"
    success_url = reverse_lazy("sent_integration:message_list")

    def form_valid(self, form):
        client = get_sent_client()
        message_record = SentMessage.objects.create(
            phone_number=form.cleaned_data["phone_number"],
            template_id=form.cleaned_data["template_id"],
            template_name=form.cleaned_data["template_name"],
            variables=form.cleaned_data["variables"],
            channels=form.cleaned_data["channels"],
            status="pending",
        )
        message_queued.send(sender=SentMessage, instance=message_record)

        try:
            result = client.messages.send(
                phone_number=form.cleaned_data["phone_number"],
                template_id=form.cleaned_data["template_id"],
                template_name=form.cleaned_data["template_name"],
                variables=form.cleaned_data["variables"],
                channels=form.cleaned_data["channels"],
            )

            if result.success:
                message_record.mark_as_sent(result.data.id)
                message_sent.send(sender=SentMessage, instance=message_record, response=result.data)
                messages.success(self.request, f"Message sent! ID: {result.data.id}")
            else:
                message_record.mark_as_failed(result.error.message)
                message_failed.send(sender=SentMessage, instance=message_record, error=result.error.message)
                messages.error(self.request, f"Failed: {result.error.message}")
        except Exception as e:
            logger.exception("Error sending message")
            message_record.mark_as_failed(str(e))
            message_failed.send(sender=SentMessage, instance=message_record, error=str(e))
            messages.error(self.request, f"Error: {str(e)}")

        return super().form_valid(form)


class MessageListView(LoginRequiredMixin, TemplateView):
    template_name = "sent_integration/message_list.html"

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["messages"] = SentMessage.objects.all()[:100]
        return context


@method_decorator(csrf_exempt, name="dispatch")
class WebhookView(View):
    http_method_names = ["post", "head", "options"]

    def post(self, request, *args, **kwargs):
        client = get_sent_client()
        signature = request.headers.get("X-Webhook-Signature")
        if not signature:
            return JsonResponse({"error": "Missing signature"}, status=401)

        payload = request.body.decode("utf-8")
        webhook_secret = getattr(settings, "SENT_DM_WEBHOOK_SECRET", None)
        if webhook_secret:
            is_valid = client.webhooks.verify_signature(
                payload=payload, signature=signature, secret=webhook_secret
            )
            if not is_valid:
                return JsonResponse({"error": "Invalid signature"}, status=401)

        try:
            event = json.loads(payload)
        except json.JSONDecodeError:
            return JsonResponse({"error": "Invalid JSON"}, status=400)

        event_type = event.get("type")
        event_data = event.get("data", {})
        return self.handle_event(event_type, event_data)

    def handle_event(self, event_type: str, event_data: dict) -> JsonResponse:
        handlers = {
            "message.status.updated": self.handle_status_update,
            "message.delivered": self.handle_delivered,
            "message.failed": self.handle_failed,
        }
        handler = handlers.get(event_type)
        if handler:
            return handler(event_data)
        return JsonResponse({"received": True, "handled": False})

    def handle_delivered(self, data: dict) -> JsonResponse:
        message_id = data.get("id")
        try:
            message = SentMessage.objects.get(sent_id=message_id)
            message.mark_as_delivered()
            message_delivered.send(sender=SentMessage, instance=message, event_data=data)
        except SentMessage.DoesNotExist:
            logger.warning(f"Message not found: {message_id}")
        return JsonResponse({"received": True})

    def handle_failed(self, data: dict) -> JsonResponse:
        message_id = data.get("id")
        error_message = data.get("error", {}).get("message", "Unknown error")
        try:
            message = SentMessage.objects.get(sent_id=message_id)
            message.mark_as_failed(error_message)
            message_failed.send(sender=SentMessage, instance=message, error=error_message)
        except SentMessage.DoesNotExist:
            logger.warning(f"Message not found: {message_id}")
        return JsonResponse({"received": True})

    def handle_status_update(self, data: dict) -> JsonResponse:
        # Handle status updates - implementation similar to above
        return JsonResponse({"received": True})


# Additional views: SendWelcomeMessageView, MessageDetailView, APIStatusView...
# See full source for complete implementations

Middleware

middleware.py

import time
import logging
import uuid
from typing import Callable
from django.http import HttpRequest, HttpResponse
from django.conf import settings

logger = logging.getLogger("sent_dm")


class SentRequestLoggingMiddleware:
    def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]):
        self.get_response = get_response

    def __call__(self, request: HttpRequest) -> HttpResponse:
        if not self._is_sent_request(request):
            return self.get_response(request)

        correlation_id = str(uuid.uuid4())[:8]
        request.sent_correlation_id = correlation_id
        start_time = time.time()

        try:
            response = self.get_response(request)
            duration = (time.time() - start_time) * 1000
            logger.info("[Sent %s] Response: %d - Duration: %.2fms",
                       correlation_id, response.status_code, duration)
            response["X-Correlation-ID"] = correlation_id
            return response
        except Exception as e:
            duration = (time.time() - start_time) * 1000
            logger.error("[Sent %s] Request failed after %.2fms: %s",
                        correlation_id, duration, str(e), exc_info=True)
            raise

    def _is_sent_request(self, request: HttpRequest) -> bool:
        webhook_path = getattr(settings, "SENT_DM_WEBHOOK_PATH", "/webhooks/sent/")
        if request.path.startswith(webhook_path):
            return True
        resolver = getattr(request, "resolver_match", None)
        if resolver and resolver.namespace == "sent_integration":
            return True
        return False


# Additional middleware: SentWebhookSecurityMiddleware...
# See full source for complete implementations

Admin Configuration

admin.py

from django.contrib import admin
from django.utils.html import format_html
from .models import SentMessage, MessageTemplate


@admin.register(SentMessage)
class SentMessageAdmin(admin.ModelAdmin):
    list_display = ["id", "phone_number", "template_name", "status_badge", "sent_at", "created_at"]
    list_filter = ["status", "template_name", "channels", "created_at"]
    search_fields = ["phone_number", "sent_id", "template_id", "template_name"]
    readonly_fields = ["sent_id", "sent_at", "delivered_at", "created_at", "updated_at"]
    date_hierarchy = "created_at"
    ordering = ["-created_at"]
    actions = ["mark_as_failed", "retry_message"]

    def status_badge(self, obj: SentMessage) -> str:
        colors = {
            "pending": "orange", "queued": "blue", "sent": "purple",
            "delivered": "green", "read": "teal", "failed": "red", "cancelled": "gray",
        }
        color = colors.get(obj.status, "gray")
        return format_html('<span style="background-color: {}; color: white; padding: 2px 8px; '
                          'border-radius: 4px; font-size: 0.8em;">{}</span>', color, obj.status.upper())
    status_badge.short_description = "Status"

    @admin.action(description="Mark selected messages as failed")
    def mark_as_failed(self, request, queryset):
        updated = queryset.update(status="failed")
        self.message_user(request, f"{updated} messages marked as failed.")

    @admin.action(description="Retry selected failed messages")
    def retry_message(self, request, queryset):
        from .tasks import send_message_task
        count = 0
        for message in queryset.filter(status="failed"):
            send_message_task.delay(
                phone_number=message.phone_number,
                template_id=message.template_id,
                template_name=message.template_name,
                variables=message.variables,
                channels=message.channels,
            )
            count += 1
        self.message_user(request, f"Queued {count} messages for retry.")


@admin.register(MessageTemplate)
class MessageTemplateAdmin(admin.ModelAdmin):
    list_display = ["name", "template_id", "is_active", "last_synced"]
    list_filter = ["is_active", "channels"]
    search_fields = ["name", "template_id", "description"]
    readonly_fields = ["last_synced"]

Celery Tasks

tasks.py

import logging
from celery import shared_task
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from sent_dm.exceptions import RateLimitError, APIError

from .apps import get_sent_client
from .models import SentMessage
from .signals import message_sent, message_failed

logger = logging.getLogger(__name__)


@shared_task(bind=True, max_retries=3, default_retry_delay=60, autoretry_for=(RateLimitError,))
def send_message_task(self, phone_number: str, template_id: str, template_name: str = None,
                      variables: dict = None, channels: list = None, message_record_id: int = None):
    client = get_sent_client()
    message_record = None
    if message_record_id:
        try:
            message_record = SentMessage.objects.get(id=message_record_id)
        except SentMessage.DoesNotExist:
            logger.warning(f"Message record {message_record_id} not found")

    try:
        result = client.messages.send(
            phone_number=phone_number,
            template_id=template_id,
            template_name=template_name or template_id,
            variables=variables or {},
            channels=channels or ["whatsapp"],
        )

        if result.success:
            if message_record:
                message_record.mark_as_sent(result.data.id)
                message_sent.send(sender=SentMessage, instance=message_record, response=result.data)
            return {"success": True, "message_id": result.data.id}
        else:
            error_msg = result.error.message if result.error else "Unknown error"
            if message_record:
                message_record.mark_as_failed(error_msg)
                message_failed.send(sender=SentMessage, instance=message_record, error=error_msg)
            raise APIError(error_msg)
    except RateLimitError as exc:
        retry_delay = 60 * (2 ** self.request.retries)
        raise self.retry(exc=exc, countdown=retry_delay)


@shared_task
def send_bulk_messages_task(phone_numbers: list[str], template_id: str, template_name: str = None,
                            variables: dict = None, channels: list = None):
    from .tasks import send_message_task
    task_ids = []
    for phone_number in phone_numbers:
        message_record = SentMessage.objects.create(
            phone_number=phone_number,
            template_id=template_id,
            template_name=template_name or template_id,
            variables=variables or {},
            channels=channels or ["whatsapp"],
            status="pending",
        )
        task = send_message_task.delay(
            phone_number=phone_number, template_id=template_id, template_name=template_name,
            variables=variables, channels=channels, message_record_id=message_record.id
        )
        task_ids.append(task.id)
    return {"queued": len(task_ids), "task_ids": task_ids}


# Additional tasks: process_webhook_event_task, retry_failed_messages_task...
# See full source for complete implementations

Django REST Framework Integration

serializers.py

import re
from rest_framework import serializers
from .models import SentMessage, MessageTemplate


class SendMessageSerializer(serializers.Serializer):
    phone_number = serializers.CharField(max_length=20)
    template_id = serializers.CharField(max_length=100)
    template_name = serializers.CharField(max_length=100, required=False)
    variables = serializers.DictField(required=False, default=dict)
    channels = serializers.ListField(child=serializers.CharField(), required=False, default=list)
    async_send = serializers.BooleanField(required=False, default=False)

    def validate_phone_number(self, value: str) -> str:
        phone = re.sub(r"\s+", "", value.strip())
        if not re.compile(r"^\+[1-9]\d{1,14}$").match(phone):
            raise serializers.ValidationError("Enter a valid phone number in E.164 format.")
        return phone


class SentMessageSerializer(serializers.ModelSerializer):
    class Meta:
        model = SentMessage
        fields = ["id", "sent_id", "phone_number", "template_id", "template_name",
                  "variables", "status", "channels", "error_message", "sent_at",
                  "delivered_at", "created_at", "updated_at"]
        read_only_fields = ["id", "sent_id", "status", "error_message", "sent_at",
                           "delivered_at", "created_at", "updated_at"]


# Additional serializers: MessageTemplateSerializer, BulkMessageSerializer...
# See full source for complete implementations

api_views.py (Key Parts)

from rest_framework import viewsets, status
from rest_framework.decorators import action, api_view, permission_classes
from rest_framework.permissions import IsAuthenticated, AllowAny
from rest_framework.response import Response
from .models import SentMessage, MessageTemplate
from .apps import get_sent_client
from .tasks import send_message_task, send_bulk_messages_task
from .serializers import SendMessageSerializer, SentMessageSerializer, MessageTemplateSerializer


class MessageViewSet(viewsets.ModelViewSet):
    queryset = SentMessage.objects.all()
    serializer_class = SentMessageSerializer
    permission_classes = [IsAuthenticated]

    def get_queryset(self):
        queryset = SentMessage.objects.all()
        status_filter = self.request.query_params.get("status")
        if status_filter:
            queryset = queryset.filter(status=status_filter)
        return queryset.order_by("-created_at")

    @action(detail=False, methods=["post"], url_path="send")
    def send_message(self, request):
        serializer = SendMessageSerializer(data=request.data)
        serializer.is_valid(raise_exception=True)
        data = serializer.validated_data

        message_record = SentMessage.objects.create(
            phone_number=data["phone_number"],
            template_id=data["template_id"],
            template_name=data.get("template_name", data["template_id"]),
            variables=data.get("variables", {}),
            channels=data.get("channels", ["whatsapp"]),
            status="pending",
        )

        if data.get("async_send", False):
            task = send_message_task.delay(
                phone_number=data["phone_number"], template_id=data["template_id"],
                template_name=data.get("template_name"), variables=data.get("variables"),
                channels=data.get("channels"), message_record_id=message_record.id
            )
            return Response({"success": True, "task_id": task.id, "status": "queued"},
                          status=status.HTTP_202_ACCEPTED)

        # Synchronous sending
        client = get_sent_client()
        result = client.messages.send(
            phone_number=data["phone_number"], template_id=data["template_id"],
            template_name=data.get("template_name", data["template_id"]),
            variables=data.get("variables", {}), channels=data.get("channels", ["whatsapp"])
        )

        if result.success:
            message_record.mark_as_sent(result.data.id)
            return Response({"success": True, "message_id": result.data.id})
        else:
            error_msg = result.error.message if result.error else "Unknown error"
            message_record.mark_as_failed(error_msg)
            return Response({"success": False, "error": error_msg}, status=status.HTTP_400_BAD_REQUEST)


# Additional viewsets: TemplateViewSet, webhook_api, api_status...
# See full source for complete implementations

Testing (Condensed)

Test Configuration

# myproject/settings_test.py
from .settings import *

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.sqlite3",
        "NAME": ":memory:",
    }
}

SENT_DM_API_KEY = "your_api_key_here"
SENT_DM_WEBHOOK_SECRET = "your_webhook_secret_here"
CELERY_TASK_ALWAYS_EAGER = True
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]

Example Tests

# sent_integration/tests/test_forms.py
from django.test import TestCase
from sent_integration.forms import SendMessageForm

class SendMessageFormTest(TestCase):
    def test_valid_form(self):
        data = {
            "phone_number": "+1234567890",
            "template_id": "welcome-template",
            "template_name": "welcome",
            "variables": {"name": "John"},
            "channels": "whatsapp, sms",
        }
        form = SendMessageForm(data)
        self.assertTrue(form.is_valid())

    def test_invalid_phone_number(self):
        data = {"phone_number": "1234567890", "template_id": "welcome", "template_name": "welcome"}
        form = SendMessageForm(data)
        self.assertFalse(form.is_valid())
        self.assertIn("phone_number", form.errors)

    # Additional test cases...
    # See full source for complete implementations
# sent_integration/tests/test_models.py
from django.test import TestCase
from sent_integration.models import SentMessage

class SentMessageModelTest(TestCase):
    def test_create_message(self):
        message = SentMessage.objects.create(
            sent_id="msg_123", phone_number="+1234567890",
            template_id="welcome", template_name="welcome", status="pending"
        )
        self.assertEqual(str(message), "welcome to +1234567890 (pending)")

    def test_mark_as_sent(self):
        message = SentMessage.objects.create(
            phone_number="+1234567890", template_id="welcome", template_name="welcome"
        )
        message.mark_as_sent("sent_msg_123")
        self.assertEqual(message.sent_id, "sent_msg_123")
        self.assertEqual(message.status, "sent")

    # Additional test cases: test_mark_as_delivered, test_mark_as_failed...
    # See full source for complete implementations
# sent_integration/tests/test_views.py
import json
from unittest.mock import Mock, patch
from django.test import TestCase, RequestFactory
from sent_integration.models import SentMessage
from sent_integration.views import WebhookView

class WebhookViewTest(TestCase):
    def setUp(self):
        self.factory = RequestFactory()
        self.view = WebhookView()

    @patch("sent_integration.views.get_sent_client")
    def test_webhook_signature_verification(self, mock_get_client):
        mock_client = Mock()
        mock_client.webhooks.verify_signature.return_value = True
        mock_get_client.return_value = mock_client

        payload = json.dumps({"type": "message.delivered", "data": {"id": "msg_123"}})
        request = self.factory.post("/webhooks/sent/", data=payload, content_type="application/json")
        request.headers = {"X-Webhook-Signature": "valid_signature"}

        with self.settings(SENT_DM_WEBHOOK_SECRET="secret"):
            response = self.view.post(request)

        self.assertEqual(response.status_code, 200)

    # Additional test cases: test_views, test_forms...
    # See full source for complete implementations

URL Patterns

sent_integration/urls.py

from django.urls import path
from . import views

app_name = "sent_integration"

urlpatterns = [
    path("messages/", views.MessageListView.as_view(), name="message_list"),
    path("messages/<int:pk>/", views.MessageDetailView.as_view(), name="message_detail"),
    path("messages/send/", views.SendMessageView.as_view(), name="send_message"),
    path("messages/send/welcome/", views.SendWelcomeMessageView.as_view(), name="send_welcome"),
    path("webhook/", views.WebhookView.as_view(), name="webhook"),
    path("api/status/", views.APIStatusView.as_view(), name="api_status"),
]

Project urls.py

from django.contrib import admin
from django.urls import path, include

urlpatterns = [
    path("admin/", admin.site.urls),
    path("sent/", include("sent_integration.urls", namespace="sent_integration")),
    path("webhooks/sent/", include("sent_integration.urls")),
]

Running Tests

# Run all tests
python manage.py test sent_integration

# Run specific test file
python manage.py test sent_integration.tests.test_forms

# Run with coverage
pip install coverage
coverage run --source=sent_integration manage.py test sent_integration
coverage report

Management Commands

# Sync all templates from Sent
python manage.py sync_templates

# Sync specific template
python manage.py sync_templates --template-id=template_123

# Dry run
python manage.py sync_templates --dry-run

# Send test message
python manage.py send_test_message +1234567890 --template-id=welcome --var=name=John

Next Steps

On this page