Projet

Général

Profil

0001-smsfactor-initial-implementation-69363.patch

A. Berriot, 29 septembre 2022 11:56

Télécharger (22,4 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           | 207 ++++++++++++++++++
 .../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, 430 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.jsonresponse import APIError
31

  
32

  
33
class SMSFactorSMSGateway(SMSResource):
34
    auth_token = models.CharField(verbose_name=_('Auth Token'), max_length=255)
35
    credit_threshold_alert = models.PositiveIntegerField(
36
        verbose_name=_('Credit alert threshold'), default=500
37
    )
38
    credit_left = models.PositiveIntegerField(verbose_name=_('Credit left'), default=0, editable=False)
39
    alert_emails = ArrayField(
40
        models.EmailField(blank=True),
41
        blank=True,
42
        null=True,
43
        verbose_name=_('Email addresses list to send credit alerts to, separated by comma'),
44
    )
45
    credit_alert_timestamp = models.DateTimeField(null=True, editable=False)
46

  
47
    # unecessary field
48
    allow_premium_rate = None
49

  
50
    class Meta:
51
        verbose_name = 'SMS Factor'
52
        db_table = 'sms_factor'
53

  
54
    TEST_DEFAULTS = {
55
        'create_kwargs': {
56
            'auth_token': 'yyy',
57
            'credit_threshold_alert': 1000,
58
        },
59
        'test_vectors': [
60
            {
61
                'status_code': 200,
62
                'response': {
63
                    "status": -7,
64
                    "message": "Erreur de données",
65
                    "details": "Texte du message introuvable",
66
                },
67
                'result': {
68
                    'err': 1,
69
                    'err_desc': 'SMS Factor error: some destinations failed',
70
                    'data': [
71
                        ['33688888888', "Texte du message introuvable"],
72
                        ['33677777777', "Texte du message introuvable"],
73
                    ],
74
                },
75
            },
76
            {
77
                'status_code': 200,
78
                'response': {
79
                    "status": 1,
80
                    "message": "OK",
81
                    "ticket": "14672468",
82
                    "cost": 2,
83
                    "credits": 642,
84
                    "total": 2,
85
                    "sent": 2,
86
                    "blacklisted": 0,
87
                    "duplicated": 0,
88
                    "invalid": 0,
89
                    "npai": 0,
90
                },
91
                'result': {
92
                    'err': 0,
93
                    'data': {
94
                        "status": 1,
95
                        "message": "OK",
96
                        "ticket": "14672468",
97
                        "cost": 2,
98
                        "credits": 642,
99
                        "total": 2,
100
                        "sent": 2,
101
                        "blacklisted": 0,
102
                        "duplicated": 0,
103
                        "invalid": 0,
104
                        "npai": 0,
105
                    },
106
                },
107
            },
108
        ],
109
    }
110
    URL = 'https://api.smsfactor.com'
111

  
112
    def request(self, method, endpoint, **kwargs):
113
        url = urllib.parse.urljoin(self.URL, endpoint)
114

  
115
        headers = {
116
            "Authorization": f"Bearer {self.auth_token}",
117
            "Accept": "application/json",
118
        }
119

  
120
        try:
121
            response = self.requests.request(method, url, headers=headers, **kwargs)
122
        except requests.RequestException as e:
123
            raise APIError('SMS Factor: request failed, %s' % e)
124
        else:
125
            try:
126
                result = response.json()
127
            except ValueError:
128
                raise APIError('SMS Factor: bad JSON response')
129
        try:
130
            response.raise_for_status()
131
        except requests.RequestException as e:
132
            raise APIError('SMS Factor: %s "%s"' % (e, result))
133
        return result
134

  
135
    def send_msg(self, text, sender, destinations, **kwargs):
136
        """Send a SMS using the SMS Factor provider"""
137
        # from https://dev.smsfactor.com/en/api/sms/send/send-single
138
        # and https://dev.smsfactor.com/en/api/sms/send/send-simulate
139
        # set destinations phone number in E.164 format (without the + prefix)
140
        # [country code][phone number including area code]
141
        endpoint = 'send'
142
        numbers = []
143
        for dest in destinations:
144
            numbers.append(dest[2:])
145
        destinations = numbers
146

  
147
        results = []
148

  
149
        for dest in destinations:
150
            params = {
151
                'sender': sender,
152
                'text': text,
153
                'to': dest,
154
                'pushtype': 'alert' if kwargs.get('stop', False) else 'marketing',
155
            }
156
            data = self.request('get', endpoint, params=params)
157
            logging.info('SMS Factor answered with %s', data)
158
            results.append(data)
159

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

  
162
        consumed_credits = None
163
        try:
164
            self.credit_left = results[-1]['credits']
165
            consumed_credits = sum((r['cost'] for r in results))
166
            self.save(update_fields=['credit_left'])
167
        except KeyError:
168
            # no credits key, there was probably an error with the request
169
            pass
170
        if any(errors):
171
            raise APIError('SMS Factor error: some destinations failed', data=errors)
172
        return consumed_credits
173

  
174
    def update_credit_left(self):
175
        result = self.request('get', endpoint='credits')
176
        self.credit_left = result['credits']
177
        self.save(update_fields=['credit_left'])
178

  
179
    def send_credit_alert_if_needed(self):
180
        if self.credit_left >= self.credit_threshold_alert:
181
            return
182
        if self.credit_alert_timestamp and self.credit_alert_timestamp > timezone.now() - datetime.timedelta(
183
            days=1
184
        ):
185
            return  # alerts are sent daily
186
        ctx = {
187
            'connector': self,
188
            'connector_url': urllib.parse.urljoin(settings.SITE_BASE_URL, self.get_absolute_url()),
189
        }
190
        subject = render_to_string('smsfactor/credit_alert_subject.txt', ctx).strip()
191
        body = render_to_string('smsfactor/credit_alert_body.txt', ctx)
192
        html_body = render_to_string('smsfactor/credit_alert_body.html', ctx)
193
        send_mail(
194
            subject,
195
            body,
196
            settings.DEFAULT_FROM_EMAIL,
197
            self.alert_emails,
198
            html_message=html_body,
199
        )
200
        self.credit_alert_timestamp = timezone.now()
201
        self.save()
202
        self.logger.warning('credit is too low, alerts were sent to %s', self.alert_emails)
203

  
204
    def hourly(self):
205
        super().hourly()
206
        self.update_credit_left()
207
        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.twilio',
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
-