Projet

Général

Profil

0001-smsfactor-initial-implementation-69363.patch

A. Berriot, 22 septembre 2022 12:01

Télécharger (24 ko)

Voir les différences:

Subject: [PATCH] smsfactor: initial implementation (#69363)

 passerelle/apps/smsfactor/__init__.py         |   0
 .../apps/smsfactor/migrations/0001_initial.py | 116 ++++++++
 .../apps/smsfactor/migrations/__init__.py     |   0
 passerelle/apps/smsfactor/models.py           | 249 ++++++++++++++++++
 .../smsfactor/credit_alert_body.html          |  22 ++
 .../templates/smsfactor/credit_alert_body.txt |  17 ++
 .../smsfactor/credit_alert_subject.txt        |   6 +
 passerelle/settings.py                        |   1 +
 tests/test_sms.py                             |  67 ++++-
 9 files changed, 472 insertions(+), 6 deletions(-)
 create mode 100644 passerelle/apps/smsfactor/__init__.py
 create mode 100644 passerelle/apps/smsfactor/migrations/0001_initial.py
 create mode 100644 passerelle/apps/smsfactor/migrations/__init__.py
 create mode 100644 passerelle/apps/smsfactor/models.py
 create mode 100644 passerelle/apps/smsfactor/templates/smsfactor/credit_alert_body.html
 create mode 100644 passerelle/apps/smsfactor/templates/smsfactor/credit_alert_body.txt
 create mode 100644 passerelle/apps/smsfactor/templates/smsfactor/credit_alert_subject.txt
passerelle/apps/smsfactor/migrations/0001_initial.py
1
# Generated by Django 2.2.26 on 2022-09-21 08:39
2

  
3
import django.contrib.postgres.fields
4
import django.core.validators
5
from django.db import migrations, models
6

  
7
import passerelle.sms.models
8

  
9

  
10
class Migration(migrations.Migration):
11

  
12
    initial = True
13

  
14
    dependencies = [
15
        ('base', '0029_auto_20210202_1627'),
16
    ]
17

  
18
    operations = [
19
        migrations.CreateModel(
20
            name='SMSFactorSMSGateway',
21
            fields=[
22
                (
23
                    'id',
24
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
25
                ),
26
                ('title', models.CharField(max_length=50, verbose_name='Title')),
27
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
28
                ('description', models.TextField(verbose_name='Description')),
29
                (
30
                    'default_country_code',
31
                    models.CharField(
32
                        default='33',
33
                        max_length=3,
34
                        validators=[
35
                            django.core.validators.RegexValidator(
36
                                '^[0-9]*$', 'The country must only contain numbers'
37
                            )
38
                        ],
39
                        verbose_name='Default country code',
40
                    ),
41
                ),
42
                (
43
                    'default_trunk_prefix',
44
                    models.CharField(
45
                        default='0',
46
                        max_length=2,
47
                        validators=[
48
                            django.core.validators.RegexValidator(
49
                                '^[0-9]*$', 'The trunk prefix must only contain numbers'
50
                            )
51
                        ],
52
                        verbose_name='Default trunk prefix',
53
                    ),
54
                ),
55
                (
56
                    'max_message_length',
57
                    models.IntegerField(
58
                        default=2000,
59
                        help_text='Messages over this limit will be truncated.',
60
                        verbose_name='Maximum message length',
61
                    ),
62
                ),
63
                (
64
                    'authorized',
65
                    django.contrib.postgres.fields.ArrayField(
66
                        base_field=models.CharField(
67
                            choices=[
68
                                ('fr-metro', 'France mainland (+33 [67])'),
69
                                ('fr-domtom', 'France DOM/TOM (+262, etc.)'),
70
                                ('be', 'Belgian (+32 4[5-9]) '),
71
                                ('all', 'All'),
72
                            ],
73
                            max_length=32,
74
                            null=True,
75
                        ),
76
                        default=passerelle.sms.models.authorized_default,
77
                        size=None,
78
                        verbose_name='Authorized Countries',
79
                    ),
80
                ),
81
                ('auth_token', models.CharField(max_length=255, verbose_name='Auth Token')),
82
                (
83
                    'credit_threshold_alert',
84
                    models.PositiveIntegerField(default=500, verbose_name='Credit alert threshold'),
85
                ),
86
                (
87
                    'credit_left',
88
                    models.PositiveIntegerField(default=0, editable=False, verbose_name='Credit left'),
89
                ),
90
                (
91
                    'alert_emails',
92
                    django.contrib.postgres.fields.ArrayField(
93
                        base_field=models.EmailField(blank=True, max_length=254),
94
                        blank=True,
95
                        null=True,
96
                        size=None,
97
                        verbose_name='Email addresses list to send credit alerts to, separated by comma',
98
                    ),
99
                ),
100
                ('credit_alert_timestamp', models.DateTimeField(editable=False, null=True)),
101
                (
102
                    'users',
103
                    models.ManyToManyField(
104
                        blank=True,
105
                        related_name='_smsfactorsmsgateway_users_+',
106
                        related_query_name='+',
107
                        to='base.ApiUser',
108
                    ),
109
                ),
110
            ],
111
            options={
112
                'verbose_name': 'SMS Factor',
113
                'db_table': 'sms_factor',
114
            },
115
        ),
116
    ]
passerelle/apps/smsfactor/models.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2020  Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
import datetime
17
import logging
18
import urllib.parse
19

  
20
import requests
21
from django.conf import settings
22
from django.contrib.postgres.fields import ArrayField
23
from django.core.mail import send_mail
24
from django.db import models
25
from django.template.loader import render_to_string
26
from django.utils import timezone
27
from django.utils.translation import gettext_lazy as _
28

  
29
from passerelle.sms.models import SMSResource
30
from passerelle.utils.api import endpoint
31
from passerelle.utils.jsonresponse import APIError
32

  
33
SEND_SCHEMA = {
34
    '$schema': 'http://json-schema.org/draft-04/schema#',
35
    "type": "object",
36
    'required': ['message', 'to'],
37
    'properties': {
38
        'message': {
39
            'description': 'String message',
40
            'type': 'string',
41
            'examples': ['Votre demande a été mise à jour.'],
42
        },
43
        'from': {'description': 'Sender identifier', 'type': 'string', 'examples': ['Mairie de Marseille']},
44
        'to': {
45
            'description': 'Destination numbers',
46
            "type": "array",
47
            "items": {'type': 'string', 'pattern': r'^\+?[-.\s/\d]+$'},
48
            "examples": [["0612345678"]],
49
        },
50
    },
51
}
52

  
53

  
54
class SMSFactorSMSGateway(SMSResource):
55
    auth_token = models.CharField(verbose_name=_('Auth Token'), max_length=255)
56
    credit_threshold_alert = models.PositiveIntegerField(
57
        verbose_name=_('Credit alert threshold'), default=500
58
    )
59
    credit_left = models.PositiveIntegerField(verbose_name=_('Credit left'), default=0, editable=False)
60
    alert_emails = ArrayField(
61
        models.EmailField(blank=True),
62
        blank=True,
63
        null=True,
64
        verbose_name=_('Email addresses list to send credit alerts to, separated by comma'),
65
    )
66
    credit_alert_timestamp = models.DateTimeField(null=True, editable=False)
67

  
68
    # unecessary field
69
    allow_premium_rate = None
70

  
71
    class Meta:
72
        verbose_name = 'SMS Factor'
73
        db_table = 'sms_factor'
74

  
75
    TEST_DEFAULTS = {
76
        'create_kwargs': {
77
            'auth_token': 'yyy',
78
            'credit_threshold_alert': 1000,
79
        },
80
        'test_vectors': [
81
            {
82
                'status_code': 200,
83
                'response': {
84
                    "status": -7,
85
                    "message": "Erreur de données",
86
                    "details": "Texte du message introuvable",
87
                },
88
                'result': {
89
                    'err': 1,
90
                    'err_desc': 'SMS Factor error: some destinations failed',
91
                    'data': [
92
                        ['33688888888', "Texte du message introuvable"],
93
                        ['33677777777', "Texte du message introuvable"],
94
                    ],
95
                },
96
            },
97
            {
98
                'status_code': 200,
99
                'response': {
100
                    "status": 1,
101
                    "message": "OK",
102
                    "ticket": "14672468",
103
                    "cost": 2,
104
                    "credits": 642,
105
                    "total": 2,
106
                    "sent": 2,
107
                    "blacklisted": 0,
108
                    "duplicated": 0,
109
                    "invalid": 0,
110
                    "npai": 0,
111
                },
112
                'result': {
113
                    'err': 0,
114
                    'data': {
115
                        "status": 1,
116
                        "message": "OK",
117
                        "ticket": "14672468",
118
                        "cost": 2,
119
                        "credits": 642,
120
                        "total": 2,
121
                        "sent": 2,
122
                        "blacklisted": 0,
123
                        "duplicated": 0,
124
                        "invalid": 0,
125
                        "npai": 0,
126
                    },
127
                },
128
            },
129
        ],
130
    }
131
    URL = 'https://api.smsfactor.com'
132

  
133
    @endpoint(
134
        perm='can_send_messages',
135
        methods=['post'],
136
        description=_('Send a SMS message'),
137
        parameters={
138
            'simulate': {
139
                'description': _('Do not actually send a SMS to the recipient, for testing purpose.'),
140
                'example_value': 'true',
141
            }
142
        },
143
        post={'request_body': {'schema': {'application/json': SEND_SCHEMA}}},
144
    )
145
    def send(self, request, post_data, simulate=False):
146
        post_data['message'] = post_data['message'][: self.max_message_length]
147
        post_data['to'] = self.clean_numbers(post_data['to'])
148
        post_data['to'], warnings = self.authorize_numbers(post_data['to'])
149
        logging.info('sending SMS to %r from %r', post_data['to'], post_data['from'])
150
        self.add_job(
151
            'send_job',
152
            text=post_data['message'],
153
            sender=post_data['from'],
154
            destinations=post_data['to'],
155
            simulate=simulate,
156
        )
157
        return {'err': 0, 'warn': warnings}
158

  
159
    def request(self, method, endpoint, **kwargs):
160
        url = urllib.parse.urljoin(self.URL, endpoint)
161

  
162
        headers = {
163
            "Authorization": f"Bearer {self.auth_token}",
164
            "Accept": "application/json",
165
        }
166

  
167
        try:
168
            response = self.requests.request(method, url, headers=headers, **kwargs)
169
        except requests.RequestException as e:
170
            raise APIError('SMS Factor: request failed, %s' % e)
171
        else:
172
            try:
173
                result = response.json()
174
            except ValueError:
175
                raise APIError('SMS Factor: bad JSON response')
176
        try:
177
            response.raise_for_status()
178
        except requests.RequestException as e:
179
            raise APIError('SMS Factor: %s "%s"' % (e, result))
180
        return result
181

  
182
    def send_msg(self, text, sender, destinations, **kwargs):
183
        """Send a SMS using the SMS Factor provider"""
184
        # from https://dev.smsfactor.com/en/api/sms/send/send-single
185
        # and https://dev.smsfactor.com/en/api/sms/send/send-simulate
186
        # set destinations phone number in E.164 format (without the + prefix)
187
        # [country code][phone number including area code]
188
        endpoint = 'send/simulate' if kwargs.get('simulate', False) else 'send'
189
        numbers = []
190
        for dest in destinations:
191
            numbers.append(dest[2:])
192
        destinations = numbers
193

  
194
        results = []
195

  
196
        for dest in destinations:
197
            params = {'text': text, 'to': dest, 'pushtype': 'alert'}
198
            data = self.request('get', endpoint, params=params)
199
            logging.info('SMS Factor answered with %s', data)
200
            results.append(data)
201

  
202
        errors = [f'SMS Factor error: {r["status"]}: {r["message"]}' for r in results if r['status'] != 1]
203

  
204
        consumed_credits = None
205
        try:
206
            self.credit_left = results[-1]['credits']
207
            consumed_credits = sum([r['cost'] for r in results])
208
            self.save(update_fields=['credit_left'])
209
        except KeyError:
210
            # no credits key, there was probably an error with the request
211
            pass
212
        if any(errors):
213
            raise APIError('SMS Factor error: some destinations failed', data=errors)
214
        return consumed_credits
215

  
216
    def update_credit_left(self):
217
        result = self.request('get', endpoint='credits')
218
        self.credit_left = result['credits']
219
        self.save(update_fields=['credit_left'])
220

  
221
    def send_credit_alert_if_needed(self):
222
        if self.credit_left >= self.credit_threshold_alert:
223
            return
224
        if self.credit_alert_timestamp and self.credit_alert_timestamp > timezone.now() - datetime.timedelta(
225
            days=1
226
        ):
227
            return  # alerts are sent daily
228
        ctx = {
229
            'connector': self,
230
            'connector_url': urllib.parse.urljoin(settings.SITE_BASE_URL, self.get_absolute_url()),
231
        }
232
        subject = render_to_string('smsfactor/credit_alert_subject.txt', ctx).strip()
233
        body = render_to_string('smsfactor/credit_alert_body.txt', ctx)
234
        html_body = render_to_string('smsfactor/credit_alert_body.html', ctx)
235
        send_mail(
236
            subject,
237
            body,
238
            settings.DEFAULT_FROM_EMAIL,
239
            self.alert_emails,
240
            html_message=html_body,
241
        )
242
        self.credit_alert_timestamp = timezone.now()
243
        self.save()
244
        self.logger.warning('credit is too low, alerts were sent to %s', self.alert_emails)
245

  
246
    def hourly(self):
247
        super().hourly()
248
        self.update_credit_left()
249
        self.send_credit_alert_if_needed()
passerelle/apps/smsfactor/templates/smsfactor/credit_alert_body.html
1
{% extends "emails/body_base.html" %}
2
{% load i18n %}
3

  
4
{% block content %}
5
<p>{% trans "Hi," %}</p>
6

  
7
<p>
8
{% blocktrans trimmed with name=connector.title credit_left=connector.credit_left %}
9
There are only {{ credit_left }} credits left for connector {{ name }}.
10
{% endblocktrans %}
11
</p>
12

  
13
<p>
14
{% blocktrans trimmed with account=connector.account %}
15
Please add more credit as soon as possible for your SMS Factor account.
16
{% endblocktrans %}
17
</p>
18

  
19
{% with _("View connector page") as button_label %}
20
{% include "emails/button-link.html" with url=connector_url label=button_label %}
21
{% endwith %}
22
{% endblock %}
passerelle/apps/smsfactor/templates/smsfactor/credit_alert_body.txt
1
{% extends "emails/body_base.txt" %}
2
{% load i18n %}
3

  
4
{% block content %}{% autoescape off %}{% trans "Hi," %}
5

  
6
{% blocktrans trimmed with name=connector.title credit_left=connector.credit_left %}
7
There are only {{ credit_left }} credits left for connector {{ name }}.
8
{% endblocktrans %}
9

  
10
{% blocktrans trimmed with account=connector.account %}
11
Please add more credit as soon as possible for your SMS Factor account..
12
{% endblocktrans %}
13

  
14
{% trans "View connector page:" %} {{ connector_url }}
15

  
16
{% endautoescape %}
17
{% endblock %}
passerelle/apps/smsfactor/templates/smsfactor/credit_alert_subject.txt
1
{% extends "emails/subject.txt" %}
2
{% load i18n %}
3

  
4
{% block email-subject %}{% autoescape off %}{% blocktrans trimmed with credit_left=connector.credit_left %}
5
SMS Factor alert: only {{ credit_left }} credits left
6
{% endblocktrans %}{% endautoescape %}{% endblock %}
passerelle/settings.py
168 168
    'passerelle.apps.sfr_dmc',
169 169
    'passerelle.apps.signal_arretes',
170 170
    'passerelle.apps.sivin',
171
    'passerelle.apps.smsfactor',
171 172
    'passerelle.apps.soap',
172 173
    'passerelle.apps.solis',
173 174
    'passerelle.apps.sp_fr',
tests/test_sms.py
26 26
from passerelle.apps.choosit.models import ChoositSMSGateway
27 27
from passerelle.apps.ovh.models import OVHSMSGateway
28 28
from passerelle.apps.sfr_dmc.models import SfrDmcGateway
29
from passerelle.apps.smsfactor.models import SMSFactorSMSGateway
29 30
from passerelle.base.models import AccessRight, ApiUser, Job, ResourceLog
30 31
from passerelle.sms.models import SMSLog, SMSResource
31 32
from passerelle.utils.jsonresponse import APIError
......
270 271
        assert send_function.call_args[1]['text'] == 'a' * connector.max_message_length
271 272

  
272 273

  
273
@pytest.mark.parametrize('connector', [OVHSMSGateway], indirect=True)
274
@pytest.mark.parametrize('connector', [OVHSMSGateway, SMSFactorSMSGateway], indirect=True)
274 275
def test_sms_log(app, connector):
275 276
    path = '/%s/%s/send/' % (connector.get_connector_slug(), connector.slug)
276 277
    assert not SMSLog.objects.filter(appname=connector.get_connector_slug(), slug=connector.slug).exists()
......
280 281
        'from': '+33699999999',
281 282
        'to': ['+33688888888'],
282 283
    }
283
    with mock.patch.object(OVHSMSGateway, 'send_msg') as send_function:
284

  
285
    with mock.patch.object(connector.__class__, 'send_msg') as send_function:
284 286
        send_function.return_value = 1
285 287
        app.post_json(path, params=payload)
286 288
        connector.jobs()
......
288 290
            appname=connector.get_connector_slug(), slug=connector.slug, credits=1
289 291
        ).exists()
290 292

  
291
    with mock.patch.object(OVHSMSGateway, 'send_msg') as send_function:
293
    with mock.patch.object(connector.__class__, 'send_msg') as send_function:
292 294
        send_function.return_value = 2
293 295
        app.post_json(path, params=payload)
294 296
        connector.jobs()
......
297 299
        ).exists()
298 300

  
299 301

  
300
@pytest.mark.parametrize('connector', [OVHSMSGateway], indirect=True)
302
@pytest.mark.parametrize('connector', [OVHSMSGateway, SMSFactorSMSGateway], indirect=True)
301 303
def test_sms_job_details_credits(admin_user, app, connector, caplog):
302 304
    path = '/%s/%s/send/' % (connector.get_connector_slug(), connector.slug)
303 305
    payload = {'message': 'plop', 'from': '+33699999999', 'to': ['+33688888888']}
304
    with mock.patch.object(OVHSMSGateway, 'send_msg') as send_function:
306
    with mock.patch.object(connector.__class__, 'send_msg') as send_function:
305 307
        send_function.return_value = 1
306 308
        app.post_json(path, params=payload)
307 309
        job1_id = Job.objects.get(method_name='send_job', status='registered').id
......
323 325

  
324 326

  
325 327
def test_sms_nostop_parameter(app, connector):
328
    if isinstance(connector, SMSFactorSMSGateway):
329
        pytest.skip(f"nostop parameter not supported for {connector}")
326 330
    base_path = '/%s/%s/send/?nostop=1' % (connector.get_connector_slug(), connector.slug)
327 331
    payload = {
328 332
        'message': 'not a spam',
......
342 346
            assert send_function.call_args[1]['stop'] == ('nostop' not in path)
343 347

  
344 348

  
345
@pytest.mark.parametrize('connector', [OVHSMSGateway], indirect=True)
349
@pytest.mark.parametrize('connector', [OVHSMSGateway, SMSFactorSMSGateway], indirect=True)
346 350
@pytest.mark.parametrize(
347 351
    'to, destination',
348 352
    [
......
768 772
    _check_media_type('standard GSM message', 'SMSLong')
769 773
    _check_media_type('usual standard GSM characters : \'"-\r\n!?éèù%à*+=€@[]|', 'SMSLong')
770 774
    _check_media_type('unicode message 😀', 'SMSUnicodeLong')
775

  
776

  
777
def test_sms_factor_alert_emails(app, freezer, mailoutbox):
778
    connector = SMSFactorSMSGateway.objects.create(
779
        slug='test-sms-factor',
780
        title='Test SMS Factor',
781
        auth_token='foo',
782
        credit_threshold_alert=100,
783
        credit_left=102,
784
        alert_emails=['test@entrouvert.org'],
785
    )
786
    api = ApiUser.objects.create(username='apiuser')
787
    obj_type = ContentType.objects.get_for_model(connector)
788
    AccessRight.objects.create(
789
        codename='can_send_messages', apiuser=api, resource_type=obj_type, resource_pk=connector.pk
790
    )
791

  
792
    freezer.move_to('2019-01-01 00:00:00')
793
    resp = {'credits': 101}
794
    url = connector.URL
795
    with tests.utils.mock_url(url, resp, 200):
796
        connector.hourly()
797
    assert len(mailoutbox) == 0
798

  
799
    resp = {'credits': 99}
800
    url = connector.URL
801
    with tests.utils.mock_url(url, resp, 200):
802
        connector.hourly()
803
    assert len(mailoutbox) == 1
804

  
805
    mail = mailoutbox[0]
806
    assert mail.recipients() == ['test@entrouvert.org']
807
    assert mail.subject == 'SMS Factor alert: only 99 credits left'
808
    for body in (mail.body, mail.alternatives[0][0]):
809
        assert "SMS Factor" in body
810
        assert connector.title in body
811
        assert 'http://localhost/smsfactor/test-sms-factor/' in body
812
    mailoutbox.clear()
813

  
814
    # alert is sent again daily
815
    freezer.move_to('2019-01-01 12:00:00')
816
    resp = {'credits': 99}
817
    url = connector.URL
818
    with tests.utils.mock_url(url, resp, 200):
819
        connector.hourly()
820
    assert len(mailoutbox) == 0
821

  
822
    freezer.move_to('2019-01-02 01:00:07')
823
    with tests.utils.mock_url(url, resp, 200):
824
        connector.hourly()
825
    assert len(mailoutbox) == 1
771
-