From 01707a2e4d93159ee8c6043d3e62fd83d93ebdfc Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Fri, 24 Apr 2020 18:05:35 +0200
Subject: [PATCH 3/3] sms: add endpoint to send sms asynchronously (#21465)

---
 passerelle/apps/choosit/models.py | 22 ++++++++++++
 passerelle/apps/mobyt/models.py   | 17 +++++++++
 passerelle/apps/orange/models.py  | 18 ++++++++++
 passerelle/apps/ovh/models.py     | 44 ++++++++++++++++++++++++
 passerelle/apps/oxyd/models.py    | 17 +++++++++
 passerelle/apps/twilio/models.py  | 14 ++++++++
 passerelle/base/models.py         | 28 +++++++++++++++
 tests/test_sms.py                 | 57 ++++++++++++++++++++++++++++++-
 tests/utils.py                    |  4 ++-
 9 files changed, 219 insertions(+), 2 deletions(-)

diff --git a/passerelle/apps/choosit/models.py b/passerelle/apps/choosit/models.py
index 6b2efe5a..ed4f58bf 100644
--- a/passerelle/apps/choosit/models.py
+++ b/passerelle/apps/choosit/models.py
@@ -104,15 +104,37 @@ class ChoositSMSGateway(SMSResource):
                         results.append(u'Choosit error: %s' % output['error'])
                     else:
                         results.append(output)
         if any(isinstance(result, string_types) for result in results):
             raise APIError('Choosit error: some destinations failed',
                            data=list(zip(destinations, results)))
         return list(zip(destinations, results))
 
+    def send_one_msg(self, text, sender, destination, **kwargs):
+        """Send a uniq SMS using the Twilio provider"""
+        destination = self.clean_number(destination,
+                                        self.default_country_code,
+                                        self.default_trunk_prefix)
+        params = {
+            'key': self.key,
+            'recipient': destination,
+            'content': text[:160],
+        }
+        data = {'data': json.dumps(params)}
+        r = self.requests.post(self.URL, data=data)
+        try:
+            output = r.json()
+        except ValueError:
+            raise APIError('Choosit error: bad JSON response')
+        else:
+            if not isinstance(output, dict):
+                raise APIError('Choosit error: JSON response is not a dict %r' % output)
+            elif 'error' in output:
+                raise APIError('Choosit error: %s' % output['error'])
+
 
 class ChoositSMS(SMS):
     gateway = models.ForeignKey(ChoositSMSGateway, related_name='sms_set')
 
     class Meta:
         verbose_name = _('Choosit SMS')
         db_table = 'sms_choosit_sms'
diff --git a/passerelle/apps/mobyt/models.py b/passerelle/apps/mobyt/models.py
index b9e36670..166c7bf5 100644
--- a/passerelle/apps/mobyt/models.py
+++ b/passerelle/apps/mobyt/models.py
@@ -64,15 +64,32 @@ class MobytSMSGateway(SMSResource):
         try:
             r = self.requests.post(self.URL, data=params)
         except requests.RequestException as e:
             raise APIError('MobyT error: POST failed, %s' % e)
         if r.content[:2] != "OK":
             raise APIError('MobyT error: response is not "OK"')
         return None
 
+    def send_one_msg(self, text, sender, destination, **kwargs):
+        """Send a uniq SMS using the Mobyt provider"""
+        destination = self.clean_number(destination,
+                                        self.default_country_code,
+                                        self.default_trunk_prefix)
+        params = {
+            'user': self.username,
+            'pass': self.password,
+            'rcpt': destination,
+            'data': text,
+            'sender': sender,
+            'qty': self.quality,
+        }
+        r = self.requests.post(self.URL, data=params)
+        if r.content[:2] != "OK":
+            raise APIError('MobyT error: response is not "OK"')
+
 
 class MobytSMS(SMS):
     gateway = models.ForeignKey(MobytSMSGateway, related_name='sms_set')
 
     class Meta:
         verbose_name = _('Mobyt SMS')
         db_table = 'sms_mobyt_sms'
diff --git a/passerelle/apps/orange/models.py b/passerelle/apps/orange/models.py
index d6a4d4c3..117b8f23 100644
--- a/passerelle/apps/orange/models.py
+++ b/passerelle/apps/orange/models.py
@@ -53,16 +53,25 @@ class OrangeSMSGateway(SMSResource):
         verbose_name=_('Default trunk prefix'), max_length=2, default='0')
 
     manager_view_template_name = 'passerelle/manage/messages_service_view.html'
 
     class Meta:
         verbose_name = _('Orange')
         db_table = 'sms_orange'
 
+    TEST_DEFAULTS = {
+        'create_kwargs': {
+            'username': 'jdoe',
+            'password': 'xxxx',
+            'groupname': 'group1',
+        },
+        'test_vectors': [],
+    }
+
     def get_access_token(self):
         headers = {'content-type': 'application/x-www-form-urlencoded'}
         params = {'username': self.username, 'password': self.password}
         response = self.requests.post(URL_TOKEN, data=params, headers=headers)
         if response.status_code != 200:
             raise APIError('Bad username or password: %s, %s' % (
                 response.status_code, response.text))
         response_json = get_json(response)
@@ -110,15 +119,24 @@ class OrangeSMSGateway(SMSResource):
         '''Send a SMS using the Orange provider'''
         destinations = self.clean_numbers(
             destinations, self.default_country_code, self.default_trunk_prefix)
         access_token = self.get_access_token()
         group_id = self.group_id_from_name(access_token)
         response = self.diffusion(access_token, group_id, destinations, text)
         return response
 
+    def send_one_msg(self, text, sender, destination, **kwargs):
+        '''Send a SMS using the Orange provider'''
+        destination = self.clean_number(
+            destination, self.default_country_code, self.default_trunk_prefix)
+        access_token = self.get_access_token()
+        group_id = self.group_id_from_name(access_token)
+        response = self.diffusion(access_token, group_id, [destination], text)
+        return response
+
 
 class OrangeSMS(SMS):
     gateway = models.ForeignKey(OrangeSMSGateway, related_name='sms_set')
 
     class Meta:
         verbose_name = _('Orange SMS')
         db_table = 'sms_orange_sms'
diff --git a/passerelle/apps/ovh/models.py b/passerelle/apps/ovh/models.py
index ce85fccf..7975bcc8 100644
--- a/passerelle/apps/ovh/models.py
+++ b/passerelle/apps/ovh/models.py
@@ -123,15 +123,59 @@ class OVHSMSGateway(SMSResource):
                                           (self.slug, credit_left, self.credit_threshold_alert))
                     ret['credit_left'] = credit_left
                     ret['ovh_result'] = result
                     ret['sms_ids'] = result.get('SmsIds', [])
                     return ret
                 else:
                     raise APIError('OVH error: %r' % result)
 
+    def send_one_msg(self, text, sender, destination, **kwargs):
+        """Send a uniq SMS using the OVH provider"""
+
+        destination = self.clean_number(destination,
+                                        self.default_country_code,
+                                        self.default_trunk_prefix)
+        text = force_text(text).encode('utf-8')
+        to = destination
+        params = {
+            'account': self.account.encode('utf-8'),
+            'login': self.username.encode('utf-8'),
+            'password': self.password.encode('utf-8'),
+            'from': sender.encode('utf-8'),
+            'to': to,
+            'message': text,
+            'contentType': 'text/json',
+            'class': self.msg_class,
+        }
+        if not kwargs['stop']:
+            params.update({'noStop': 1})
+        response = self.requests.post(self.URL, data=params)
+        try:
+            result = response.json()
+        except ValueError:
+            raise APIError('OVH error: bad JSON response')
+        else:
+            if not isinstance(result, dict):
+                raise APIError('OVH error: bad JSON response %r, it should be a dictionnary' %
+                               result)
+            if 100 <= result['status'] < 200:
+                ret = {}
+                credit_left = float(result['creditLeft'])
+                # update credit left
+                OVHSMSGateway.objects.filter(id=self.id).update(credit_left=credit_left)
+                if credit_left < self.credit_threshold_alert:
+                    ret['warning'] = ('credit level too low for %s: %s (threshold %s)' %
+                                      (self.slug, credit_left, self.credit_threshold_alert))
+                ret['credit_left'] = credit_left
+                ret['ovh_result'] = result
+                ret['sms_ids'] = result.get('SmsIds', [])
+                return ret
+            else:
+                raise APIError('OVH error: %r' % result)
+
 
 class OVHSMS(SMS):
     gateway = models.ForeignKey(OVHSMSGateway, related_name='sms_set')
 
     class Meta:
         verbose_name = _('OVH SMS')
         db_table = 'sms_ovh_sms'
diff --git a/passerelle/apps/oxyd/models.py b/passerelle/apps/oxyd/models.py
index e7bf0490..f5bafae5 100644
--- a/passerelle/apps/oxyd/models.py
+++ b/passerelle/apps/oxyd/models.py
@@ -82,15 +82,32 @@ class OxydSMSGateway(SMSResource):
         return None
 
     def get_sms_left(self, type='standard'):
         raise NotImplementedError
 
     def get_money_left(self):
         raise NotImplementedError
 
+    def send_one_msg(self, text, sender, destination, **kwargs):
+        """Send a uniq SMS using the Oxyd provider"""
+        # unfortunately it lacks a batch API...
+        destination = self.clean_number(
+            destination, self.default_country_code, self.default_trunk_prefix)
+        params = {
+            'id': self.username,
+            'pass': self.password,
+            'num': destination,
+            'sms': text.encode('utf-8'),
+            'flash': '0',
+        }
+        r = self.requests.post(self.URL, params)
+        code = r.content and r.content.split()[0]
+        if force_text(code) != '200':
+            APIError('OXYD error: response is not 200')
+
 
 class OxydSMS(SMS):
     gateway = models.ForeignKey(OxydSMSGateway, related_name='sms_set')
 
     class Meta:
         verbose_name = _('Oxyd SMS')
         db_table = 'sms_oxyd_sms'
diff --git a/passerelle/apps/twilio/models.py b/passerelle/apps/twilio/models.py
index c2f7ea67..7915ae92 100644
--- a/passerelle/apps/twilio/models.py
+++ b/passerelle/apps/twilio/models.py
@@ -84,15 +84,29 @@ class TwilioSMSGateway(SMSResource):
                 else:
                     results.append(0)
         if any(results):
             raise APIError(
                 'Twilio error: some destinations failed',
                 data=list(zip(destinations, results)))
         return None
 
+    def send_one_msg(self, text, sender, destination, **kwargs):
+        """Send a uniq SMS using the Twilio provider"""
+
+        url = '%s/%s/Messages.json' % (TwilioSMSGateway.URL, self.account_sid)
+        auth = HTTPBasicAuth(self.account_sid, self.auth_token)
+        params = {
+            'Body': text,
+            'From': sender,
+            'To': destination
+        }
+        resp = self.requests.post(url, params, auth=auth)
+        if resp.status_code != 201:
+            raise APIError('Twilio error: %s' % resp.text)
+
 
 class TwilioSMS(SMS):
     gateway = models.ForeignKey(TwilioSMSGateway, related_name='sms_set')
 
     class Meta:
         verbose_name = _('Twilio SMS')
         db_table = 'sms_twilio_sms'
diff --git a/passerelle/base/models.py b/passerelle/base/models.py
index 39b77a27..574a4451 100644
--- a/passerelle/base/models.py
+++ b/passerelle/base/models.py
@@ -976,16 +976,44 @@ class SMSResource(BaseResource):
 
     @endpoint(perm='can_send_messages', methods=['post'])
     def send(self, request, *args, **kwargs):
         parameters = self.get_parameters(request, *args, **kwargs)
         result = {'data': self.send_msg(**parameters)}
         SMSLog.objects.create(appname=self.get_connector_slug(), slug=self.slug)
         return result
 
+    def send_one_msg_wrapper(self, sms_id):
+        sms = self.sms_set.get(id=sms_id)
+        try:
+            self.send_one_msg(sms.text, sms.sender, sms.destination, stop=sms.stop)
+        except requests.RequestException:
+            self.add_job('send_one_msg_wrapper', after_timestamp=5 * 60, sms_id=sms.id)
+            raise
+        except APIError as exc:
+            sms.status = SMS.STATUS_ERROR
+            sms.status_details = str(exc)
+        else:
+            sms.status = SMS.STATUS_PUSHED
+        sms.save()
+
+    @endpoint(perm='can_send_messages', methods=['post'])
+    def send_async(self, request, *args, **kwargs):
+        parameters = self.get_parameters(request, *args, **kwargs)
+        for destination in parameters['destinations']:
+            sms = self.sms_set.create(
+                text=parameters['text'],
+                sender=parameters['sender'],
+                destination=destination,
+                stop=parameters['stop'],
+                status=SMS.STATUS_PENDING
+            )
+            self.add_job('send_one_msg_wrapper', sms_id=sms.id)
+        SMSLog.objects.create(appname=self.get_connector_slug(), slug=self.slug)
+
     class Meta:
         abstract = True
 
 
 class SMS(models.Model):
     STATUS_PENDING = 'pending'
     STATUS_PUSHED = 'pushed'
     STATUS_ERROR = 'error'
diff --git a/tests/test_sms.py b/tests/test_sms.py
index a8224e88..81ead9e8 100644
--- a/tests/test_sms.py
+++ b/tests/test_sms.py
@@ -1,15 +1,16 @@
 import mock
 import pytest
+from requests import RequestException
 
 from django.contrib.contenttypes.models import ContentType
 
 from passerelle.apps.ovh.models import OVHSMSGateway
-from passerelle.base.models import ApiUser, AccessRight, SMSResource, SMSLog
+from passerelle.base.models import ApiUser, AccessRight, Job, SMSResource, SMS, SMSLog
 
 from test_manager import login, admin_user
 
 import utils
 
 pytestmark = pytest.mark.django_db
 
 klasses = SMSResource.__subclasses__()
@@ -61,16 +62,70 @@ def test_connectors(app, connector):
                 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_send_async(app, connector, freezer):
+    path = '/%s/%s/send_async/' % (connector.get_connector_slug(), connector.slug)
+    result = app.post_json(path, params={})
+    assert result.json['err'] == 1
+    assert result.json['err_desc'].startswith('Payload error: ')
+
+    payload = {
+        'message': 'hello',
+        'from': '+33699999999',
+        'to': ['+33688888888', '+33677777777'],
+    }
+    test_vectors = getattr(connector, 'TEST_DEFAULTS', {}).get('test_vectors', [])
+    total = len(test_vectors) * len(payload['to'])
+    assert Job.objects.count() == 0
+    assert connector.sms_set.count() == 0
+    for test_vector in test_vectors:
+
+        # register job
+        freezer.move_to('2019-01-01 00:00:00')
+        result = app.post_json(path, params=payload)
+        assert len(Job.objects.filter(status='registered')) == len(payload['to'])
+        new_job_ids = [x.id for x in Job.objects.filter(status='registered')]
+
+        # transport error
+        with utils.mock_url(connector.URL, exception=RequestException):
+            connector.jobs()
+        for job_id in new_job_ids:
+            job = Job.objects.get(id=job_id)
+            assert job.status == 'failed'
+            assert 'RequestException' in job.status_details['error_summary']
+        assert len(Job.objects.filter(status='registered')) == len(payload['to'])
+        new_job_ids = [x.id for x in Job.objects.filter(status='registered')]
+
+        # perform jobs
+        freezer.move_to('2019-01-01 00:05:01')
+        with utils.mock_url(
+                connector.URL,
+                test_vector.get('response', ''),
+                test_vector.get('status_code', 200)):
+            connector.jobs()
+        for job_id in new_job_ids:
+            job = Job.objects.get(id=job_id)
+            assert job.status == 'completed'
+            sms_id = job.parameters['sms_id']
+            sms = connector.sms_set.get(id=sms_id)
+            if sms.status == SMS.STATUS_ERROR:
+                if test_vector['result'].get('data'):
+                    assert sms.status_details == test_vector['result']['data'][0][1]
+            else:
+                assert sms.status == SMS.STATUS_PUSHED
+
+    assert Job.objects.count() == total * 2  # transport error + perform jobs
+    assert connector.sms_set.count() == total
+    assert SMSLog.objects.count() == total / len(payload['to'])
 
 def test_manage_views(admin_user, app, connector):
     url = '/%s/%s/' % (connector.get_connector_slug(), connector.slug)
     resp = app.get(url)
     assert 'Endpoints' in resp.text
     assert not 'accessright/add' in resp.text
     app = login(app)
     resp = app.get(url)
diff --git a/tests/utils.py b/tests/utils.py
index 62fc82bc..6af91756 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -27,30 +27,32 @@ def setup_access_rights(obj):
 
 class FakedResponse(mock.Mock):
     headers = {}
 
     def json(self):
         return json_loads(self.content)
 
 
-def mock_url(url=None, response='', status_code=200, headers=None):
+def mock_url(url=None, response='', status_code=200, headers=None, exception=None):
     urlmatch_kwargs = {}
     if url:
         parsed = urlparse.urlparse(url)
         if parsed.netloc:
             urlmatch_kwargs['netloc'] = parsed.netloc
         if parsed.path:
             urlmatch_kwargs['path'] = parsed.path
 
     if not isinstance(response, str):
         response = json.dumps(response)
 
     @httmock.urlmatch(**urlmatch_kwargs)
     def mocked(url, request):
+        if exception:
+            raise exception
         return httmock.response(status_code, response, headers, request=request)
     return httmock.HTTMock(mocked)
 
 
 def make_resource(model_class, **kwargs):
     resource = model_class.objects.create(**kwargs)
     setup_access_rights(resource)
     return resource
-- 
2.26.2

