From d958b5ed48e805f88aa5cee702541ccb1216fad6 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Thu, 9 Apr 2020 20:09:55 +0200
Subject: [PATCH] sms: add a twilio connector (#19663)

---
 passerelle/apps/twilio/__init__.py            |  0
 .../apps/twilio/migrations/0001_initial.py    | 34 +++++++
 passerelle/apps/twilio/migrations/__init__.py |  0
 passerelle/apps/twilio/models.py              | 90 +++++++++++++++++++
 passerelle/settings.py                        |  1 +
 tests/test_sms.py                             |  6 +-
 6 files changed, 130 insertions(+), 1 deletion(-)
 create mode 100644 passerelle/apps/twilio/__init__.py
 create mode 100644 passerelle/apps/twilio/migrations/0001_initial.py
 create mode 100644 passerelle/apps/twilio/migrations/__init__.py
 create mode 100644 passerelle/apps/twilio/models.py

diff --git a/passerelle/apps/twilio/__init__.py b/passerelle/apps/twilio/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/twilio/migrations/0001_initial.py b/passerelle/apps/twilio/migrations/0001_initial.py
new file mode 100644
index 00000000..6c182f50
--- /dev/null
+++ b/passerelle/apps/twilio/migrations/0001_initial.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-04-09 18:07
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0018_smslog'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='TwilioSMSGateway',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=50, verbose_name='Title')),
+                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+                ('description', models.TextField(verbose_name='Description')),
+                ('max_message_length', models.IntegerField(default=160, verbose_name='Maximum message length')),
+                ('account_sid', models.CharField(max_length=64, verbose_name='Account Sid')),
+                ('auth_token', models.CharField(max_length=64, verbose_name='Auth Token')),
+                ('users', models.ManyToManyField(blank=True, related_name='_twiliosmsgateway_users_+', related_query_name='+', to='base.ApiUser')),
+            ],
+            options={
+                'verbose_name': 'Twilio',
+                'db_table': 'sms_twilio',
+            },
+        ),
+    ]
diff --git a/passerelle/apps/twilio/migrations/__init__.py b/passerelle/apps/twilio/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/twilio/models.py b/passerelle/apps/twilio/models.py
new file mode 100644
index 00000000..807c59b9
--- /dev/null
+++ b/passerelle/apps/twilio/models.py
@@ -0,0 +1,90 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2020  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+import requests
+from requests.auth import HTTPBasicAuth
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.utils.jsonresponse import APIError
+from passerelle.base.models import SMSResource
+
+
+class TwilioSMSGateway(SMSResource):
+    account_sid = models.CharField(verbose_name=_('Account Sid'), max_length=64)
+    auth_token = models.CharField(verbose_name=_('Auth Token'), max_length=64)
+
+    manager_view_template_name = 'passerelle/manage/messages_service_view.html'
+
+    class Meta:
+        verbose_name = 'Twilio'
+        db_table = 'sms_twilio'
+
+    TEST_DEFAULTS = {
+        'create_kwargs': {
+            'account_sid': 'ACxxx',
+            'auth_token': 'yyy',
+        },
+        'test_vectors': [
+            {
+                'status_code': 400,
+                'response': 'my error message',
+                'result': {
+                    'err': 1,
+                    'err_desc': 'Twilio error: some destinations failed',
+                    'data': [
+                        ['+33688888888', "Twilio error: my error message"],
+                        ['+33677777777', "Twilio error: my error message"],
+                    ],
+                }
+            },
+            {
+                'status_code': 201,
+                'result': {
+                    'err': 0,
+                    'data': None,
+                }
+            }
+        ],
+    }
+    URL = 'https://api.twilio.com/2010-04-01/Accounts'
+
+    def send_msg(self, text, sender, destinations, **kwargs):
+        """Send a SMS using the Twilio provider"""
+
+        url = '%s/%s/Messages.json' % (TwilioSMSGateway.URL, self.account_sid)
+        auth = HTTPBasicAuth(self.account_sid, self.auth_token)
+        results = []
+        for dest in destinations:
+            params = {
+                'Body': text,
+                'From': sender,
+                'To': dest
+            }
+            try:
+                resp = self.requests.post(url, params, auth=auth)
+            except requests.RequestException as e:
+                results.append('Twilio error: POST failed, %s' % e)
+            else:
+                if resp.status_code != 201:
+                    results.append('Twilio error: %s' % resp.text)
+                else:
+                    results.append(0)
+        if any(results):
+            raise APIError(
+                'Twilio error: some destinations failed',
+                data=list(zip(destinations, results)))
+        return None
diff --git a/passerelle/settings.py b/passerelle/settings.py
index 04173804..5656aeff 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -149,16 +149,17 @@ INSTALLED_APPS = (
     'passerelle.apps.opengis',
     'passerelle.apps.orange',
     'passerelle.apps.ovh',
     'passerelle.apps.oxyd',
     'passerelle.apps.pastell',
     'passerelle.apps.phonecalls',
     'passerelle.apps.solis',
     'passerelle.apps.vivaticket',
+    'passerelle.apps.twilio',
     # backoffice templates and static
     'gadjo',
 )
 
 # disable some applications for now
 PASSERELLE_APP_BDP_ENABLED = False
 PASSERELLE_APP_GDC_ENABLED = False
 PASSERELLE_APP_PASTELL_ENABLED = False
diff --git a/tests/test_sms.py b/tests/test_sms.py
index c4b9d5c9..53d6f2ca 100644
--- a/tests/test_sms.py
+++ b/tests/test_sms.py
@@ -51,17 +51,21 @@ def test_connectors(app, connector):
     assert result.json['err_desc'].startswith('Payload error: ')
 
     payload = {
         'message': 'hello',
         'from': '+33699999999',
         'to': ['+33688888888', '+33677777777'],
     }
     for test_vector in getattr(connector, 'TEST_DEFAULTS', {}).get('test_vectors', []):
-        with utils.mock_url(connector.URL, test_vector['response']):
+        with utils.mock_url(
+                connector.URL,
+                test_vector.get('response', ''),
+                test_vector.get('status_code', 200)):
+
             result = app.post_json(path, params=payload)
             for key, value in test_vector['result'].items():
                 assert key in result.json
                 assert result.json[key] == value
 
 
 def test_manage_views(admin_user, app, connector):
     url = '/%s/%s/' % (connector.get_connector_slug(), connector.slug)
-- 
2.25.1

