Projet

Général

Profil

0003-sms-add-endpoint-to-send-sms-asynchronously-21465.patch

Nicolas Roche, 24 avril 2020 18:27

Télécharger (18 ko)

Voir les différences:

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(-)
passerelle/apps/choosit/models.py
104 104
                        results.append(u'Choosit error: %s' % output['error'])
105 105
                    else:
106 106
                        results.append(output)
107 107
        if any(isinstance(result, string_types) for result in results):
108 108
            raise APIError('Choosit error: some destinations failed',
109 109
                           data=list(zip(destinations, results)))
110 110
        return list(zip(destinations, results))
111 111

  
112
    def send_one_msg(self, text, sender, destination, **kwargs):
113
        """Send a uniq SMS using the Twilio provider"""
114
        destination = self.clean_number(destination,
115
                                        self.default_country_code,
116
                                        self.default_trunk_prefix)
117
        params = {
118
            'key': self.key,
119
            'recipient': destination,
120
            'content': text[:160],
121
        }
122
        data = {'data': json.dumps(params)}
123
        r = self.requests.post(self.URL, data=data)
124
        try:
125
            output = r.json()
126
        except ValueError:
127
            raise APIError('Choosit error: bad JSON response')
128
        else:
129
            if not isinstance(output, dict):
130
                raise APIError('Choosit error: JSON response is not a dict %r' % output)
131
            elif 'error' in output:
132
                raise APIError('Choosit error: %s' % output['error'])
133

  
112 134

  
113 135
class ChoositSMS(SMS):
114 136
    gateway = models.ForeignKey(ChoositSMSGateway, related_name='sms_set')
115 137

  
116 138
    class Meta:
117 139
        verbose_name = _('Choosit SMS')
118 140
        db_table = 'sms_choosit_sms'
passerelle/apps/mobyt/models.py
64 64
        try:
65 65
            r = self.requests.post(self.URL, data=params)
66 66
        except requests.RequestException as e:
67 67
            raise APIError('MobyT error: POST failed, %s' % e)
68 68
        if r.content[:2] != "OK":
69 69
            raise APIError('MobyT error: response is not "OK"')
70 70
        return None
71 71

  
72
    def send_one_msg(self, text, sender, destination, **kwargs):
73
        """Send a uniq SMS using the Mobyt provider"""
74
        destination = self.clean_number(destination,
75
                                        self.default_country_code,
76
                                        self.default_trunk_prefix)
77
        params = {
78
            'user': self.username,
79
            'pass': self.password,
80
            'rcpt': destination,
81
            'data': text,
82
            'sender': sender,
83
            'qty': self.quality,
84
        }
85
        r = self.requests.post(self.URL, data=params)
86
        if r.content[:2] != "OK":
87
            raise APIError('MobyT error: response is not "OK"')
88

  
72 89

  
73 90
class MobytSMS(SMS):
74 91
    gateway = models.ForeignKey(MobytSMSGateway, related_name='sms_set')
75 92

  
76 93
    class Meta:
77 94
        verbose_name = _('Mobyt SMS')
78 95
        db_table = 'sms_mobyt_sms'
passerelle/apps/orange/models.py
53 53
        verbose_name=_('Default trunk prefix'), max_length=2, default='0')
54 54

  
55 55
    manager_view_template_name = 'passerelle/manage/messages_service_view.html'
56 56

  
57 57
    class Meta:
58 58
        verbose_name = _('Orange')
59 59
        db_table = 'sms_orange'
60 60

  
61
    TEST_DEFAULTS = {
62
        'create_kwargs': {
63
            'username': 'jdoe',
64
            'password': 'xxxx',
65
            'groupname': 'group1',
66
        },
67
        'test_vectors': [],
68
    }
69

  
61 70
    def get_access_token(self):
62 71
        headers = {'content-type': 'application/x-www-form-urlencoded'}
63 72
        params = {'username': self.username, 'password': self.password}
64 73
        response = self.requests.post(URL_TOKEN, data=params, headers=headers)
65 74
        if response.status_code != 200:
66 75
            raise APIError('Bad username or password: %s, %s' % (
67 76
                response.status_code, response.text))
68 77
        response_json = get_json(response)
......
110 119
        '''Send a SMS using the Orange provider'''
111 120
        destinations = self.clean_numbers(
112 121
            destinations, self.default_country_code, self.default_trunk_prefix)
113 122
        access_token = self.get_access_token()
114 123
        group_id = self.group_id_from_name(access_token)
115 124
        response = self.diffusion(access_token, group_id, destinations, text)
116 125
        return response
117 126

  
127
    def send_one_msg(self, text, sender, destination, **kwargs):
128
        '''Send a SMS using the Orange provider'''
129
        destination = self.clean_number(
130
            destination, self.default_country_code, self.default_trunk_prefix)
131
        access_token = self.get_access_token()
132
        group_id = self.group_id_from_name(access_token)
133
        response = self.diffusion(access_token, group_id, [destination], text)
134
        return response
135

  
118 136

  
119 137
class OrangeSMS(SMS):
120 138
    gateway = models.ForeignKey(OrangeSMSGateway, related_name='sms_set')
121 139

  
122 140
    class Meta:
123 141
        verbose_name = _('Orange SMS')
124 142
        db_table = 'sms_orange_sms'
passerelle/apps/ovh/models.py
123 123
                                          (self.slug, credit_left, self.credit_threshold_alert))
124 124
                    ret['credit_left'] = credit_left
125 125
                    ret['ovh_result'] = result
126 126
                    ret['sms_ids'] = result.get('SmsIds', [])
127 127
                    return ret
128 128
                else:
129 129
                    raise APIError('OVH error: %r' % result)
130 130

  
131
    def send_one_msg(self, text, sender, destination, **kwargs):
132
        """Send a uniq SMS using the OVH provider"""
133

  
134
        destination = self.clean_number(destination,
135
                                        self.default_country_code,
136
                                        self.default_trunk_prefix)
137
        text = force_text(text).encode('utf-8')
138
        to = destination
139
        params = {
140
            'account': self.account.encode('utf-8'),
141
            'login': self.username.encode('utf-8'),
142
            'password': self.password.encode('utf-8'),
143
            'from': sender.encode('utf-8'),
144
            'to': to,
145
            'message': text,
146
            'contentType': 'text/json',
147
            'class': self.msg_class,
148
        }
149
        if not kwargs['stop']:
150
            params.update({'noStop': 1})
151
        response = self.requests.post(self.URL, data=params)
152
        try:
153
            result = response.json()
154
        except ValueError:
155
            raise APIError('OVH error: bad JSON response')
156
        else:
157
            if not isinstance(result, dict):
158
                raise APIError('OVH error: bad JSON response %r, it should be a dictionnary' %
159
                               result)
160
            if 100 <= result['status'] < 200:
161
                ret = {}
162
                credit_left = float(result['creditLeft'])
163
                # update credit left
164
                OVHSMSGateway.objects.filter(id=self.id).update(credit_left=credit_left)
165
                if credit_left < self.credit_threshold_alert:
166
                    ret['warning'] = ('credit level too low for %s: %s (threshold %s)' %
167
                                      (self.slug, credit_left, self.credit_threshold_alert))
168
                ret['credit_left'] = credit_left
169
                ret['ovh_result'] = result
170
                ret['sms_ids'] = result.get('SmsIds', [])
171
                return ret
172
            else:
173
                raise APIError('OVH error: %r' % result)
174

  
131 175

  
132 176
class OVHSMS(SMS):
133 177
    gateway = models.ForeignKey(OVHSMSGateway, related_name='sms_set')
134 178

  
135 179
    class Meta:
136 180
        verbose_name = _('OVH SMS')
137 181
        db_table = 'sms_ovh_sms'
passerelle/apps/oxyd/models.py
82 82
        return None
83 83

  
84 84
    def get_sms_left(self, type='standard'):
85 85
        raise NotImplementedError
86 86

  
87 87
    def get_money_left(self):
88 88
        raise NotImplementedError
89 89

  
90
    def send_one_msg(self, text, sender, destination, **kwargs):
91
        """Send a uniq SMS using the Oxyd provider"""
92
        # unfortunately it lacks a batch API...
93
        destination = self.clean_number(
94
            destination, self.default_country_code, self.default_trunk_prefix)
95
        params = {
96
            'id': self.username,
97
            'pass': self.password,
98
            'num': destination,
99
            'sms': text.encode('utf-8'),
100
            'flash': '0',
101
        }
102
        r = self.requests.post(self.URL, params)
103
        code = r.content and r.content.split()[0]
104
        if force_text(code) != '200':
105
            APIError('OXYD error: response is not 200')
106

  
90 107

  
91 108
class OxydSMS(SMS):
92 109
    gateway = models.ForeignKey(OxydSMSGateway, related_name='sms_set')
93 110

  
94 111
    class Meta:
95 112
        verbose_name = _('Oxyd SMS')
96 113
        db_table = 'sms_oxyd_sms'
passerelle/apps/twilio/models.py
84 84
                else:
85 85
                    results.append(0)
86 86
        if any(results):
87 87
            raise APIError(
88 88
                'Twilio error: some destinations failed',
89 89
                data=list(zip(destinations, results)))
90 90
        return None
91 91

  
92
    def send_one_msg(self, text, sender, destination, **kwargs):
93
        """Send a uniq SMS using the Twilio provider"""
94

  
95
        url = '%s/%s/Messages.json' % (TwilioSMSGateway.URL, self.account_sid)
96
        auth = HTTPBasicAuth(self.account_sid, self.auth_token)
97
        params = {
98
            'Body': text,
99
            'From': sender,
100
            'To': destination
101
        }
102
        resp = self.requests.post(url, params, auth=auth)
103
        if resp.status_code != 201:
104
            raise APIError('Twilio error: %s' % resp.text)
105

  
92 106

  
93 107
class TwilioSMS(SMS):
94 108
    gateway = models.ForeignKey(TwilioSMSGateway, related_name='sms_set')
95 109

  
96 110
    class Meta:
97 111
        verbose_name = _('Twilio SMS')
98 112
        db_table = 'sms_twilio_sms'
passerelle/base/models.py
976 976

  
977 977
    @endpoint(perm='can_send_messages', methods=['post'])
978 978
    def send(self, request, *args, **kwargs):
979 979
        parameters = self.get_parameters(request, *args, **kwargs)
980 980
        result = {'data': self.send_msg(**parameters)}
981 981
        SMSLog.objects.create(appname=self.get_connector_slug(), slug=self.slug)
982 982
        return result
983 983

  
984
    def send_one_msg_wrapper(self, sms_id):
985
        sms = self.sms_set.get(id=sms_id)
986
        try:
987
            self.send_one_msg(sms.text, sms.sender, sms.destination, stop=sms.stop)
988
        except requests.RequestException:
989
            self.add_job('send_one_msg_wrapper', after_timestamp=5 * 60, sms_id=sms.id)
990
            raise
991
        except APIError as exc:
992
            sms.status = SMS.STATUS_ERROR
993
            sms.status_details = str(exc)
994
        else:
995
            sms.status = SMS.STATUS_PUSHED
996
        sms.save()
997

  
998
    @endpoint(perm='can_send_messages', methods=['post'])
999
    def send_async(self, request, *args, **kwargs):
1000
        parameters = self.get_parameters(request, *args, **kwargs)
1001
        for destination in parameters['destinations']:
1002
            sms = self.sms_set.create(
1003
                text=parameters['text'],
1004
                sender=parameters['sender'],
1005
                destination=destination,
1006
                stop=parameters['stop'],
1007
                status=SMS.STATUS_PENDING
1008
            )
1009
            self.add_job('send_one_msg_wrapper', sms_id=sms.id)
1010
        SMSLog.objects.create(appname=self.get_connector_slug(), slug=self.slug)
1011

  
984 1012
    class Meta:
985 1013
        abstract = True
986 1014

  
987 1015

  
988 1016
class SMS(models.Model):
989 1017
    STATUS_PENDING = 'pending'
990 1018
    STATUS_PUSHED = 'pushed'
991 1019
    STATUS_ERROR = 'error'
tests/test_sms.py
1 1
import mock
2 2
import pytest
3
from requests import RequestException
3 4

  
4 5
from django.contrib.contenttypes.models import ContentType
5 6

  
6 7
from passerelle.apps.ovh.models import OVHSMSGateway
7
from passerelle.base.models import ApiUser, AccessRight, SMSResource, SMSLog
8
from passerelle.base.models import ApiUser, AccessRight, Job, SMSResource, SMS, SMSLog
8 9

  
9 10
from test_manager import login, admin_user
10 11

  
11 12
import utils
12 13

  
13 14
pytestmark = pytest.mark.django_db
14 15

  
15 16
klasses = SMSResource.__subclasses__()
......
61 62
                test_vector.get('response', ''),
62 63
                test_vector.get('status_code', 200)):
63 64

  
64 65
            result = app.post_json(path, params=payload)
65 66
            for key, value in test_vector['result'].items():
66 67
                assert key in result.json
67 68
                assert result.json[key] == value
68 69

  
70
def test_send_async(app, connector, freezer):
71
    path = '/%s/%s/send_async/' % (connector.get_connector_slug(), connector.slug)
72
    result = app.post_json(path, params={})
73
    assert result.json['err'] == 1
74
    assert result.json['err_desc'].startswith('Payload error: ')
75

  
76
    payload = {
77
        'message': 'hello',
78
        'from': '+33699999999',
79
        'to': ['+33688888888', '+33677777777'],
80
    }
81
    test_vectors = getattr(connector, 'TEST_DEFAULTS', {}).get('test_vectors', [])
82
    total = len(test_vectors) * len(payload['to'])
83
    assert Job.objects.count() == 0
84
    assert connector.sms_set.count() == 0
85
    for test_vector in test_vectors:
86

  
87
        # register job
88
        freezer.move_to('2019-01-01 00:00:00')
89
        result = app.post_json(path, params=payload)
90
        assert len(Job.objects.filter(status='registered')) == len(payload['to'])
91
        new_job_ids = [x.id for x in Job.objects.filter(status='registered')]
92

  
93
        # transport error
94
        with utils.mock_url(connector.URL, exception=RequestException):
95
            connector.jobs()
96
        for job_id in new_job_ids:
97
            job = Job.objects.get(id=job_id)
98
            assert job.status == 'failed'
99
            assert 'RequestException' in job.status_details['error_summary']
100
        assert len(Job.objects.filter(status='registered')) == len(payload['to'])
101
        new_job_ids = [x.id for x in Job.objects.filter(status='registered')]
102

  
103
        # perform jobs
104
        freezer.move_to('2019-01-01 00:05:01')
105
        with utils.mock_url(
106
                connector.URL,
107
                test_vector.get('response', ''),
108
                test_vector.get('status_code', 200)):
109
            connector.jobs()
110
        for job_id in new_job_ids:
111
            job = Job.objects.get(id=job_id)
112
            assert job.status == 'completed'
113
            sms_id = job.parameters['sms_id']
114
            sms = connector.sms_set.get(id=sms_id)
115
            if sms.status == SMS.STATUS_ERROR:
116
                if test_vector['result'].get('data'):
117
                    assert sms.status_details == test_vector['result']['data'][0][1]
118
            else:
119
                assert sms.status == SMS.STATUS_PUSHED
120

  
121
    assert Job.objects.count() == total * 2  # transport error + perform jobs
122
    assert connector.sms_set.count() == total
123
    assert SMSLog.objects.count() == total / len(payload['to'])
69 124

  
70 125
def test_manage_views(admin_user, app, connector):
71 126
    url = '/%s/%s/' % (connector.get_connector_slug(), connector.slug)
72 127
    resp = app.get(url)
73 128
    assert 'Endpoints' in resp.text
74 129
    assert not 'accessright/add' in resp.text
75 130
    app = login(app)
76 131
    resp = app.get(url)
tests/utils.py
27 27

  
28 28
class FakedResponse(mock.Mock):
29 29
    headers = {}
30 30

  
31 31
    def json(self):
32 32
        return json_loads(self.content)
33 33

  
34 34

  
35
def mock_url(url=None, response='', status_code=200, headers=None):
35
def mock_url(url=None, response='', status_code=200, headers=None, exception=None):
36 36
    urlmatch_kwargs = {}
37 37
    if url:
38 38
        parsed = urlparse.urlparse(url)
39 39
        if parsed.netloc:
40 40
            urlmatch_kwargs['netloc'] = parsed.netloc
41 41
        if parsed.path:
42 42
            urlmatch_kwargs['path'] = parsed.path
43 43

  
44 44
    if not isinstance(response, str):
45 45
        response = json.dumps(response)
46 46

  
47 47
    @httmock.urlmatch(**urlmatch_kwargs)
48 48
    def mocked(url, request):
49
        if exception:
50
            raise exception
49 51
        return httmock.response(status_code, response, headers, request=request)
50 52
    return httmock.HTTMock(mocked)
51 53

  
52 54

  
53 55
def make_resource(model_class, **kwargs):
54 56
    resource = model_class.objects.create(**kwargs)
55 57
    setup_access_rights(resource)
56 58
    return resource
57
-