Projet

Général

Profil

0001-smsfactor-initial-implementation-69363.patch

A. Berriot, 03 octobre 2022 10:05

Télécharger (21,9 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           | 204 ++++++++++++++++++
 .../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                             |  65 +++++-
 9 files changed, 425 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) 2022  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
        destinations = [dest[2:] for dest in destinations]
142

  
143
        results = []
144

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

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

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

  
171
    def update_credit_left(self):
172
        result = self.request('get', endpoint='credits')
173
        self.credit_left = result['credits']
174
        self.save(update_fields=['credit_left'])
175

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

  
201
    def hourly(self):
202
        super().hourly()
203
        self.update_credit_left()
204
        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
......
342 344
            assert send_function.call_args[1]['stop'] == ('nostop' not in path)
343 345

  
344 346

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

  
774

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

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

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

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

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

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