From 75220c274f62c7efa238363f7240325bf6862649 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Fri, 24 Apr 2020 18:05:35 +0200
Subject: [PATCH 2/2] 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         | 20 ++++++++++-
 tests/test_sms.py                 | 56 ++++++++++++++++++++++++++++++-
 tests/utils.py                    |  4 ++-
 9 files changed, 209 insertions(+), 3 deletions(-)

diff --git a/passerelle/apps/choosit/models.py b/passerelle/apps/choosit/models.py
index a4678dcd..e5e2c8dc 100644
--- a/passerelle/apps/choosit/models.py
+++ b/passerelle/apps/choosit/models.py
@@ -103,8 +103,30 @@ class ChoositSMSGateway(SMSResource):
                     elif 'error' in output:
                         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'])
diff --git a/passerelle/apps/mobyt/models.py b/passerelle/apps/mobyt/models.py
index 4983b9d7..6d755657 100644
--- a/passerelle/apps/mobyt/models.py
+++ b/passerelle/apps/mobyt/models.py
@@ -65,8 +65,25 @@ 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"')
diff --git a/passerelle/apps/orange/models.py b/passerelle/apps/orange/models.py
index e1b17613..1528a407 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)
@@ -109,8 +118,17 @@ class OrangeSMSGateway(SMSResource):
     def send_msg(self, text, sender, destinations, **kwargs):
         '''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
diff --git a/passerelle/apps/ovh/models.py b/passerelle/apps/ovh/models.py
index d9d96135..b9fbd677 100644
--- a/passerelle/apps/ovh/models.py
+++ b/passerelle/apps/ovh/models.py
@@ -122,8 +122,52 @@ class OVHSMSGateway(SMSResource):
                         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)
+
+    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)
diff --git a/passerelle/apps/oxyd/models.py b/passerelle/apps/oxyd/models.py
index 5e945b16..5d9c2ca3 100644
--- a/passerelle/apps/oxyd/models.py
+++ b/passerelle/apps/oxyd/models.py
@@ -81,8 +81,25 @@ class OxydSMSGateway(SMSResource):
                 'OXYD error: some destinations failed', data=list(zip(destinations, results)))
         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')
diff --git a/passerelle/apps/twilio/models.py b/passerelle/apps/twilio/models.py
index 807c59b9..dc444c29 100644
--- a/passerelle/apps/twilio/models.py
+++ b/passerelle/apps/twilio/models.py
@@ -83,8 +83,22 @@ class TwilioSMSGateway(SMSResource):
                     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
+
+    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)
diff --git a/passerelle/base/models.py b/passerelle/base/models.py
index c1dff75d..22da00fd 100644
--- a/passerelle/base/models.py
+++ b/passerelle/base/models.py
@@ -1,11 +1,12 @@
 import collections
 import copy
 import datetime
+import hashlib
 import inspect
 import logging
 import os
 import re
 import sys
 import traceback
 import base64
 import itertools
@@ -16,17 +17,17 @@ from django.conf import settings
 from django.core.exceptions import ValidationError, ObjectDoesNotExist, PermissionDenied
 from django.core.urlresolvers import reverse
 from django.db import connection, models, transaction
 from django.db.models import Q
 from django.test import override_settings
 from django.utils.text import slugify
 from django.utils import timezone, six
 from django.utils import six
-from django.utils.encoding import force_text
+from django.utils.encoding import force_bytes, force_text
 from django.utils.six.moves.urllib.parse import urlparse
 from django.utils.translation import ugettext_lazy as _
 from django.utils.timezone import now
 from django.core.files.base import ContentFile
 
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes import fields
 
@@ -976,16 +977,33 @@ 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, *args, **kwargs):
+        try:
+            self.send_one_msg(**kwargs)
+        except requests.RequestException:
+            raise SkipJob(after_timestamp=3600 * 1)
+
+    @endpoint(perm='can_send_messages', methods=['post'])
+    def send_async(self, request, *args, **kwargs):
+        parameters = self.get_parameters(request, *args, **kwargs)
+        natural_id = hashlib.md5(force_bytes(repr(sorted(parameters.items())))).hexdigest()
+        destinations = parameters.pop('destinations')
+        for destination in destinations:
+            parameters['destination'] = destination
+            self.add_job('send_one_msg_wrapper', natural_id=natural_id, **parameters)
+        SMSLog.objects.create(appname=self.get_connector_slug(), slug=self.slug)
+        return {'err': 0, 'data': {'natural_id': natural_id}}
+
     class Meta:
         abstract = True
 
 
 @six.python_2_unicode_compatible
 class SMSLog(models.Model):
     timestamp = models.DateTimeField(auto_now_add=True)
     appname = models.CharField(max_length=128, verbose_name='appname', null=True)
diff --git a/tests/test_sms.py b/tests/test_sms.py
index a8224e88..1a61ebc7 100644
--- a/tests/test_sms.py
+++ b/tests/test_sms.py
@@ -1,15 +1,17 @@
+import isodate
 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, SMSLog
 
 from test_manager import login, admin_user
 
 import utils
 
 pytestmark = pytest.mark.django_db
 
 klasses = SMSResource.__subclasses__()
@@ -61,16 +63,68 @@ 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
+    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 result.json['err'] == 0
+        assert result.json['data'].get('natural_id')
+        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
+        freezer.move_to('2019-01-01 00:00:02')
+        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.update_timestamp == isodate.parse_datetime('2019-01-01T00:00:02+00:00')
+            assert job.status == 'registered'
+            assert job.status_details == {}
+
+        # perform jobs
+        freezer.move_to('2019-01-01 01:00:03')
+        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)
+            if job.status == 'failed':
+                assert len(job.status_details['error_summary']) > 0
+                if test_vector['result'].get('data'):
+                    assert test_vector['result']['data'][0][1] in job.status_details['error_summary']
+            else:
+                assert job.status == 'completed'
+
+    assert Job.objects.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.25.1

