Projet

Général

Profil

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

Nicolas Roche, 26 avril 2020 11:02

Télécharger (17,9 ko)

Voir les différences:

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(-)
passerelle/apps/choosit/models.py
103 103
                    elif 'error' in output:
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

  
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'])
passerelle/apps/mobyt/models.py
65 65
        }
66 66
        try:
67 67
            r = self.requests.post(self.URL, data=params)
68 68
        except requests.RequestException as e:
69 69
            raise APIError('MobyT error: POST failed, %s' % e)
70 70
        if r.content[:2] != "OK":
71 71
            raise APIError('MobyT error: response is not "OK"')
72 72
        return None
73

  
74
    def send_one_msg(self, text, sender, destination, **kwargs):
75
        """Send a uniq SMS using the Mobyt provider"""
76
        destination = self.clean_number(destination,
77
                                        self.default_country_code,
78
                                        self.default_trunk_prefix)
79
        params = {
80
            'user': self.username,
81
            'pass': self.password,
82
            'rcpt': destination,
83
            'data': text,
84
            'sender': sender,
85
            'qty': self.quality,
86
        }
87
        r = self.requests.post(self.URL, data=params)
88
        if r.content[:2] != "OK":
89
            raise APIError('MobyT error: response is not "OK"')
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)
......
109 118
    def send_msg(self, text, sender, destinations, **kwargs):
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
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
passerelle/apps/ovh/models.py
122 122
                        ret['warning'] = ('credit level too low for %s: %s (threshold %s)' %
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

  
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)
passerelle/apps/oxyd/models.py
81 81
                'OXYD error: some destinations failed', data=list(zip(destinations, results)))
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

  
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')
passerelle/apps/twilio/models.py
83 83
                    results.append('Twilio error: %s' % resp.text)
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

  
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)
passerelle/base/models.py
1 1
import collections
2 2
import copy
3 3
import datetime
4
import hashlib
4 5
import inspect
5 6
import logging
6 7
import os
7 8
import re
8 9
import sys
9 10
import traceback
10 11
import base64
11 12
import itertools
......
16 17
from django.core.exceptions import ValidationError, ObjectDoesNotExist, PermissionDenied
17 18
from django.core.urlresolvers import reverse
18 19
from django.db import connection, models, transaction
19 20
from django.db.models import Q
20 21
from django.test import override_settings
21 22
from django.utils.text import slugify
22 23
from django.utils import timezone, six
23 24
from django.utils import six
24
from django.utils.encoding import force_text
25
from django.utils.encoding import force_bytes, force_text
25 26
from django.utils.six.moves.urllib.parse import urlparse
26 27
from django.utils.translation import ugettext_lazy as _
27 28
from django.utils.timezone import now
28 29
from django.core.files.base import ContentFile
29 30

  
30 31
from django.contrib.contenttypes.models import ContentType
31 32
from django.contrib.contenttypes import fields
32 33

  
......
976 977

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

  
985
    def send_one_msg_wrapper(self, *args, **kwargs):
986
        try:
987
            self.send_one_msg(**kwargs)
988
        except requests.RequestException:
989
            raise SkipJob(after_timestamp=3600 * 1)
990

  
991
    @endpoint(perm='can_send_messages', methods=['post'])
992
    def send_async(self, request, *args, **kwargs):
993
        parameters = self.get_parameters(request, *args, **kwargs)
994
        natural_id = hashlib.md5(force_bytes(repr(sorted(parameters.items())))).hexdigest()
995
        destinations = parameters.pop('destinations')
996
        for destination in destinations:
997
            parameters['destination'] = destination
998
            self.add_job('send_one_msg_wrapper', natural_id=natural_id, **parameters)
999
        SMSLog.objects.create(appname=self.get_connector_slug(), slug=self.slug)
1000
        return {'err': 0, 'data': {'natural_id': natural_id}}
1001

  
984 1002
    class Meta:
985 1003
        abstract = True
986 1004

  
987 1005

  
988 1006
@six.python_2_unicode_compatible
989 1007
class SMSLog(models.Model):
990 1008
    timestamp = models.DateTimeField(auto_now_add=True)
991 1009
    appname = models.CharField(max_length=128, verbose_name='appname', null=True)
tests/test_sms.py
1
import isodate
1 2
import mock
2 3
import pytest
4
from requests import RequestException
3 5

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

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

  
9 11
from test_manager import login, admin_user
10 12

  
11 13
import utils
12 14

  
13 15
pytestmark = pytest.mark.django_db
14 16

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

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

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

  
77
    payload = {
78
        'message': 'hello',
79
        'from': '+33699999999',
80
        'to': ['+33688888888', '+33677777777'],
81
    }
82
    test_vectors = getattr(connector, 'TEST_DEFAULTS', {}).get('test_vectors', [])
83
    total = len(test_vectors) * len(payload['to'])
84
    assert Job.objects.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 result.json['err'] == 0
91
        assert result.json['data'].get('natural_id')
92
        assert len(Job.objects.filter(status='registered')) == len(payload['to'])
93
        new_job_ids = [x.id for x in Job.objects.filter(status='registered')]
94

  
95
        # transport error
96
        freezer.move_to('2019-01-01 00:00:02')
97
        with utils.mock_url(connector.URL, exception=RequestException):
98
            connector.jobs()
99
        for job_id in new_job_ids:
100
            job = Job.objects.get(id=job_id)
101
            assert job.update_timestamp == isodate.parse_datetime('2019-01-01T00:00:02+00:00')
102
            assert job.status == 'registered'
103
            assert job.status_details == {}
104

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

  
121
    assert Job.objects.count() == total
122
    assert SMSLog.objects.count() == total / len(payload['to'])
69 123

  
70 124
def test_manage_views(admin_user, app, connector):
71 125
    url = '/%s/%s/' % (connector.get_connector_slug(), connector.slug)
72 126
    resp = app.get(url)
73 127
    assert 'Endpoints' in resp.text
74 128
    assert not 'accessright/add' in resp.text
75 129
    app = login(app)
76 130
    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
-