Repository: roaldnefs/django-sms (language: python)

Issue:
Feature Request: Add Twilio backend
Feature request to add a Twilio backend.

For the full Twilio API reference see: https://www.twilio.com/docs/usage/api.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index abd7884..22ed506 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,8 @@
 All notable changes in **django-sms** are documented below.
 
 ## [Unreleased]
+### Added
+- The **sms.backends.twilio.SmsBackend** to send text messages using [Twilio](https://twilio.com/) ([#7](https://github.com/roaldnefs/django-sms/issues/7)).
 
 ## [0.4.0]
 ### Added
diff --git a/README.md b/README.md
index ae19b58..2fcb9ac 100644
--- a/README.md
+++ b/README.md
@@ -19,6 +19,7 @@
             - [In-memory backend](#in-memory-backend)
             - [Dummy backend](#dummy-backend)
             - [MessageBird backend](#messagebird-backend)
+            - [Twilio backend](#twilio-backend)
         - [Defining a custom SMS backend](#defining-a-custom-sms-backend)
     - [Signals](#signals)
         - [sms.signals.post_send](#sms.signals.post_send)
@@ -198,6 +199,21 @@ Make sure the MessageBird Python SDK is installed by running the following comma
 pip install "django-sms[messagebird]"
 ```
 
+#### Twilio backend
+The [Twilio](https://twilio.com/) backend sends text messages using the [Twilio SMS API](https://www.twilio.com/docs/sms/api/message-resource#create-a-message-resource). To specify this backend, put the following in your settings:
+
+```python
+SMS_BACKEND = 'sms.backends.twilio.SmsBackend'
+TWILIO_ACCOUNT_SID = 'live_redacted-twilio-account-sid'
+TWILIO_AUTH_TOKEN = 'live_redacted-twilio-auth-token'
+```
+
+Make sure the Twilio Python SDK is installed by running the following command:
+
+```console
+pip install "django-sms[twilio]"
+```
+
 ### Defining a custom SMS backend
 If you need to change how text messages are sent you can write your own SMS backend. The **SMS_BACKEND** setting in your settings file is then the Python import path for you backend class.
 
diff --git a/setup.py b/setup.py
index 5a92c50..2f4091f 100644
--- a/setup.py
+++ b/setup.py
@@ -54,5 +54,6 @@ def long_description() -> str:
     install_requires=['Django>=2.2'],
     extras_require={
         'messagebird': ['messagebird'],
+        'twilio': ['twilio'],
     }
 )
diff --git a/sms/backends/twilio.py b/sms/backends/twilio.py
new file mode 100644
index 0000000..f9517b2
--- /dev/null
+++ b/sms/backends/twilio.py
@@ -0,0 +1,68 @@
+"""
+SMS backend for sending text messages using Twilio.
+"""
+from typing import List, Optional
+
+from django.conf import settings  # type: ignore
+from django.core.exceptions import ImproperlyConfigured  # type: ignore
+
+from sms.backends.base import BaseSmsBackend
+from sms.message import Message
+
+try:
+    from twilio.rest import Client  # type: ignore
+    HAS_TWILIO = True
+except ImportError:
+    HAS_TWILIO = False
+
+
+class SmsBackend(BaseSmsBackend):
+    def __init__(self, fail_silently: bool = False, **kwargs) -> None:
+        super().__init__(fail_silently=fail_silently, **kwargs)
+
+        if not HAS_TWILIO and not self.fail_silently:
+            raise ImproperlyConfigured(
+                "You're using the SMS backend "
+                "'sms.backends.twilio.SmsBackend' without having "
+                "'twilio' installed. Install 'twilio' or use "
+                "another SMS backend."
+            )
+
+        account_sid: Optional[str] = getattr(settings, 'TWILIO_ACCOUNT_SID')
+        if not account_sid and not self.fail_silently:
+            raise ImproperlyConfigured(
+                "You're using the SMS backend "
+                "'sms.backends.twilio.SmsBackend' without having the "
+                "setting 'TWILIO_ACCOUNT_SID' set."
+            )
+
+        auth_token: Optional[str] = getattr(settings, 'TWILIO_AUTH_TOKEN')
+        if not auth_token and not self.fail_silently:
+            raise ImproperlyConfigured(
+                "You're using the SMS backend "
+                "'sms.backends.twilio.SmsBackend' without having the "
+                "setting 'TWILIO_AUTH_TOKEN' set."
+            )
+
+        self.client = None
+        if HAS_TWILIO:
+            self.client = Client(account_sid, auth_token)
+
+    def send_messages(self, messages: List[Message]) -> int:
+        if not self.client:
+            return 0
+
+        msg_count: int = 0
+        for message in messages:
+            for recipient in message.recipients:
+                try:
+                    self.client.messages.create(
+                        to=recipient,
+                        from_=message.originator,
+                        body=message.body
+                    )
+                except Exception as exc:
+                    if not self.fail_silently:
+                        raise exc
+                msg_count += 1
+        return msg_count
diff --git a/tox.ini b/tox.ini
index 2592d17..8d87d16 100644
--- a/tox.ini
+++ b/tox.ini
@@ -17,6 +17,7 @@ setenv=
   PYTHONWARNINGS=default
 deps=
     messagebird
+    twilio
     coverage
     mypy
     django22: Django==2.2.*


Test patch:
diff --git a/tests/tests.py b/tests/tests.py
index c15a967..1c0b56f 100644
--- a/tests/tests.py
+++ b/tests/tests.py
@@ -213,6 +213,39 @@ def test_send_messages(self) -> None:
         )
 
 
+class TwilioBackendTests(BaseSmsBackendTests, SimpleTestCase):
+    sms_backend = 'sms.backends.twilio.SmsBackend'
+
+    def setUp(self) -> None:
+        super().setUp()
+        self._settings_override = override_settings(
+            TWILIO_ACCOUNT_SID='fake_account_sid',
+            TWILIO_AUTH_TOKEN='fake_auth_token',
+        )
+        self._settings_override.enable()
+
+    def tearDown(self) -> None:
+        self._settings_override.disable()
+        super().tearDown()
+
+    def test_send_messages(self) -> None:
+        """Test send_messages with the Twilio backend."""
+        message = Message(
+            'Here is the message',
+            '+12065550100',
+            ['+441134960000']
+        )
+
+        connection = sms.get_connection()
+        connection.client.messages.create = MagicMock()  # type: ignore
+        connection.send_messages([message])  # type: ignore
+        connection.client.messages.create.assert_called_with(  # type: ignore
+            to='+441134960000',
+            from_='+12065550100',
+            body='Here is the message'
+        )
+
+
 class SignalTests(SimpleTestCase):
 
     def flush_mailbox(self) -> None: