0001-smsfactor-initial-implementation-69363.patch
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 |
- |