diff --git a/passerelle/apps/choosit/migrations/0010_auto_20210202_1304.py b/passerelle/apps/choosit/migrations/0010_auto_20210202_1304.py new file mode 100644 index 00000000..e29cef95 --- /dev/null +++ b/passerelle/apps/choosit/migrations/0010_auto_20210202_1304.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-02-02 12:04 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('choosit', '0009_choositsmsgateway_max_message_length'), + ] + + operations = [ + migrations.AddField( + model_name='choositsmsgateway', + name='allow_premium_rate', + field=models.BooleanField( + default=False, + help_text='This option is only applyed to France mainland', + verbose_name='Allow premium rate numbers', + ), + ), + migrations.AddField( + model_name='choositsmsgateway', + name='authorized', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ('fr-metro', 'France mainland (+33 [67])'), + ('fr-domtom', 'France DOM/TOM (+262, etc.)'), + ('be', 'Belgian (+32 4[5-9]) '), + ('all', 'All'), + ], + max_length=32, + null=True, + ), + default=['all'], + size=None, + verbose_name='Authorized Countries', + ), + ), + ] diff --git a/passerelle/apps/mobyt/migrations/0009_auto_20210202_1304.py b/passerelle/apps/mobyt/migrations/0009_auto_20210202_1304.py new file mode 100644 index 00000000..d5f79f8e --- /dev/null +++ b/passerelle/apps/mobyt/migrations/0009_auto_20210202_1304.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-02-02 12:04 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mobyt', '0008_auto_20200310_1539'), + ] + + operations = [ + migrations.AddField( + model_name='mobytsmsgateway', + name='allow_premium_rate', + field=models.BooleanField( + default=False, + help_text='This option is only applyed to France mainland', + verbose_name='Allow premium rate numbers', + ), + ), + migrations.AddField( + model_name='mobytsmsgateway', + name='authorized', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ('fr-metro', 'France mainland (+33 [67])'), + ('fr-domtom', 'France DOM/TOM (+262, etc.)'), + ('be', 'Belgian (+32 4[5-9]) '), + ('all', 'All'), + ], + max_length=32, + null=True, + ), + default=['all'], + size=None, + verbose_name='Authorized Countries', + ), + ), + ] diff --git a/passerelle/apps/orange/migrations/0009_auto_20210202_1304.py b/passerelle/apps/orange/migrations/0009_auto_20210202_1304.py new file mode 100644 index 00000000..23dee944 --- /dev/null +++ b/passerelle/apps/orange/migrations/0009_auto_20210202_1304.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-02-02 12:04 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orange', '0008_auto_20200412_1240'), + ] + + operations = [ + migrations.AddField( + model_name='orangesmsgateway', + name='allow_premium_rate', + field=models.BooleanField( + default=False, + help_text='This option is only applyed to France mainland', + verbose_name='Allow premium rate numbers', + ), + ), + migrations.AddField( + model_name='orangesmsgateway', + name='authorized', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ('fr-metro', 'France mainland (+33 [67])'), + ('fr-domtom', 'France DOM/TOM (+262, etc.)'), + ('be', 'Belgian (+32 4[5-9]) '), + ('all', 'All'), + ], + max_length=32, + null=True, + ), + default=['all'], + size=None, + verbose_name='Authorized Countries', + ), + ), + ] diff --git a/passerelle/apps/ovh/migrations/0013_auto_20210202_1304.py b/passerelle/apps/ovh/migrations/0013_auto_20210202_1304.py new file mode 100644 index 00000000..43520975 --- /dev/null +++ b/passerelle/apps/ovh/migrations/0013_auto_20210202_1304.py @@ -0,0 +1,55 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-02-02 12:04 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ovh', '0012_auto_20201027_1121'), + ] + + operations = [ + migrations.AddField( + model_name='ovhsmsgateway', + name='allow_premium_rate', + field=models.BooleanField( + default=False, + help_text='This option is only applyed to France mainland', + verbose_name='Allow premium rate numbers', + ), + ), + migrations.AddField( + model_name='ovhsmsgateway', + name='authorized', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ('fr-metro', 'France mainland (+33 [67])'), + ('fr-domtom', 'France DOM/TOM (+262, etc.)'), + ('be', 'Belgian (+32 4[5-9]) '), + ('all', 'All'), + ], + max_length=32, + null=True, + ), + default=['all'], + size=None, + verbose_name='Authorized Countries', + ), + ), + migrations.AlterField( + model_name='ovhsmsgateway', + name='alert_emails', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.EmailField(blank=True, max_length=254), + blank=True, + null=True, + size=None, + verbose_name='Email addresses list to send credit alerts to, separated by comma', + ), + ), + ] diff --git a/passerelle/apps/oxyd/migrations/0009_auto_20210202_1304.py b/passerelle/apps/oxyd/migrations/0009_auto_20210202_1304.py new file mode 100644 index 00000000..23ef8f2d --- /dev/null +++ b/passerelle/apps/oxyd/migrations/0009_auto_20210202_1304.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-02-02 12:04 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('oxyd', '0008_oxydsmsgateway_max_message_length'), + ] + + operations = [ + migrations.AddField( + model_name='oxydsmsgateway', + name='allow_premium_rate', + field=models.BooleanField( + default=False, + help_text='This option is only applyed to France mainland', + verbose_name='Allow premium rate numbers', + ), + ), + migrations.AddField( + model_name='oxydsmsgateway', + name='authorized', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ('fr-metro', 'France mainland (+33 [67])'), + ('fr-domtom', 'France DOM/TOM (+262, etc.)'), + ('be', 'Belgian (+32 4[5-9]) '), + ('all', 'All'), + ], + max_length=32, + null=True, + ), + default=['all'], + size=None, + verbose_name='Authorized Countries', + ), + ), + ] diff --git a/passerelle/apps/twilio/migrations/0002_auto_20210202_1304.py b/passerelle/apps/twilio/migrations/0002_auto_20210202_1304.py new file mode 100644 index 00000000..0e1d388b --- /dev/null +++ b/passerelle/apps/twilio/migrations/0002_auto_20210202_1304.py @@ -0,0 +1,44 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-02-02 12:04 +from __future__ import unicode_literals + +import django.contrib.postgres.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('twilio', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='twiliosmsgateway', + name='allow_premium_rate', + field=models.BooleanField( + default=False, + help_text='This option is only applyed to France mainland', + verbose_name='Allow premium rate numbers', + ), + ), + migrations.AddField( + model_name='twiliosmsgateway', + name='authorized', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.CharField( + choices=[ + ('fr-metro', 'France mainland (+33 [67])'), + ('fr-domtom', 'France DOM/TOM (+262, etc.)'), + ('be', 'Belgian (+32 4[5-9]) '), + ('all', 'All'), + ], + max_length=32, + null=True, + ), + default=['all'], + size=None, + verbose_name='Authorized Countries', + ), + ), + ] diff --git a/passerelle/sms/forms.py b/passerelle/sms/forms.py index f0baa355..39519634 100644 --- a/passerelle/sms/forms.py +++ b/passerelle/sms/forms.py @@ -1,8 +1,38 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2020 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . from django import forms from django.utils.translation import ugettext_lazy as _ +from passerelle.forms import GenericConnectorForm + class SmsTestSendForm(forms.Form): number = forms.CharField(label=_('To'), max_length=12) sender = forms.CharField(label=_('From'), max_length=12) message = forms.CharField(label=_('Message'), max_length=128) + + +class SMSConnectorForm(GenericConnectorForm): + def __init__(self, *args, **kwargs): + from passerelle.sms.models import SMSResource + + super(SMSConnectorForm, self).__init__(*args, **kwargs) + self.fields['authorized'] = forms.MultipleChoiceField( + choices=SMSResource.AUTHORIZED, + widget=forms.CheckboxSelectMultiple, + initial=[SMSResource.ALL], + label=_('Authorized Countries'), + ) diff --git a/passerelle/sms/models.py b/passerelle/sms/models.py index 9de09e26..db86dec8 100644 --- a/passerelle/sms/models.py +++ b/passerelle/sms/models.py @@ -16,6 +16,7 @@ import logging import re +from django.contrib.postgres.fields import ArrayField from django.db import models from django.utils import six from django.utils.module_loading import import_string @@ -23,9 +24,11 @@ from django.utils.translation import ugettext_lazy as _ from passerelle.base.models import BaseResource from passerelle.compat import json_loads +from passerelle.sms.forms import SMSConnectorForm from passerelle.utils.api import endpoint from passerelle.utils.jsonresponse import APIError + SEND_SCHEMA = { '$schema': 'http://json-schema.org/draft-04/schema#', "type": "object", @@ -49,6 +52,7 @@ SEND_SCHEMA = { class SMSResource(BaseResource): + manager_form_base_class = SMSConnectorForm category = _('SMS Providers') documentation_url = ( 'https://doc-publik.entrouvert.com/admin-fonctionnel/les-tutos/configuration-envoi-sms/' @@ -62,15 +66,47 @@ class SMSResource(BaseResource): default_trunk_prefix = models.CharField( verbose_name=_('Default trunk prefix'), max_length=2, default=u'0' ) # Yeah France first ! - # FIXME: add regexp field, to check destination and from format max_message_length = models.IntegerField(_('Maximum message length'), default=160) manager_view_template_name = 'passerelle/manage/messages_service_view.html' + FR_METRO = 'fr-metro' + FR_DOMTOM = 'fr-domtom' + BE_ = 'be' + ALL = 'all' + AUTHORIZED = [ + (FR_METRO, _('France mainland (+33 [67])')), + (FR_DOMTOM, _('France DOM/TOM (+262, etc.)')), + (BE_, _('Belgian (+32 4[5-9]) ')), + (ALL, _('All')), + ] + authorized = ArrayField( + models.CharField(max_length=32, null=True, choices=AUTHORIZED), + verbose_name=_('Authorized Countries'), + default=[ALL], + ) + + allow_premium_rate = models.BooleanField( + _('Allow premium rate numbers'), + default=False, + help_text=_('This option is only applyed to France mainland'), + ) + @classmethod def get_management_urls(cls): return import_string('passerelle.sms.urls.management_urlpatterns') + def _get_authorized_display(self): + result = [] + for key, value in self.AUTHORIZED: + if key in self.authorized: + result.append(str(value)) + return ', '.join(result) + + def __init__(self, *args, **kwargs): + super(SMSResource, self).__init__(*args, **kwargs) + self.get_authorized_display = self._get_authorized_display + def clean_numbers(self, destinations): numbers = [] for dest in destinations: @@ -93,6 +129,50 @@ class SMSResource(BaseResource): numbers.append(number) return numbers + def authorize_numbers(self, destinations): + number_regexes = { + 'premium_rate': [r'^0033[8]\d{8}$'], + SMSResource.FR_METRO: [r'^0033[67]\d{8}$'], + SMSResource.FR_DOMTOM: [ + r'^00262262\d{6}$', # Réunion, Mayotte, Terres australe/antarctiques + r'^508508\d{6}$', # Saint-Pierre-et-Miquelon + r'^590590\d{6}$', # Guadeloupe, Saint-Barthélemy, Saint-Martin + r'^594594\d{6}$', # Guyane + r'^596596\d{6}$', # Martinique + r'^00687[67]\d{8}$', # Nouvelle-Calédonie + ], + SMSResource.BE_: [r'^00324[5-9]\d{7}$'], + } + + premium_numbers = set() + if not self.allow_premium_rate: + regex = re.compile('|'.join(number_regexes['premium_rate'])) + premium_numbers = set(dest for dest in destinations if regex.match(dest)) + + foreign_numbers = set() + if SMSResource.ALL not in self.authorized: + regexes = [] + for country in self.authorized: + regexes += number_regexes[country] + regex = re.compile('|'.join(regexes)) + foreign_numbers = set(dest for dest in destinations if not regex.match(dest)) + + authorized_numbers = sorted(set(destinations) - foreign_numbers - premium_numbers, key=int) + + premium_numbers_string = ", ".join(sorted(premium_numbers, key=int)) + foreign_numbers_string = ", ".join(sorted(foreign_numbers - premium_numbers, key=int)) + if premium_numbers_string: + logging.warning('unauthorized premium rate phone number: %s', premium_numbers_string) + if foreign_numbers_string: + logging.warning('unauthorized foreign phone number: %s', foreign_numbers_string) + if len(authorized_numbers) == 0: + raise APIError('no phone number was authorized: %s' % ', '.join(destinations)) + warnings = { + 'deny premium rate phone numbers': premium_numbers_string, + 'deny foreign phone numbers': foreign_numbers_string, + } + return authorized_numbers, warnings + @endpoint( perm='can_send_messages', methods=['post'], @@ -103,6 +183,7 @@ class SMSResource(BaseResource): def send(self, request, post_data, nostop=None): post_data['message'] = post_data['message'][: self.max_message_length] post_data['to'] = self.clean_numbers(post_data['to']) + post_data['to'], warnings = self.authorize_numbers(post_data['to']) logging.info('sending SMS to %r from %r', post_data['to'], post_data['from']) stop = nostop is None # ?nostop in not in query string self.add_job( @@ -112,7 +193,7 @@ class SMSResource(BaseResource): destinations=post_data['to'], stop=stop, ) - return {'err': 0} + return {'err': 0, 'warn': warnings} def send_job(self, *args, **kwargs): self.send_msg(**kwargs) diff --git a/passerelle/sms/views.py b/passerelle/sms/views.py index 3c34a4db..7ccf2666 100644 --- a/passerelle/sms/views.py +++ b/passerelle/sms/views.py @@ -29,6 +29,7 @@ class SmsTestSendView(GenericConnectorMixin, FormView): connector = self.get_object() try: number = connector.clean_numbers([number])[0] + number = connector.authorize_numbers([number])[0][0] connector.send_msg(text=message, sender=sender, destinations=[number], stop=False) except APIError as exc: messages.error(self.request, _('Sending SMS fails: %s' % exc)) diff --git a/tests/test_sms.py b/tests/test_sms.py index 1e937675..36879b87 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -1,5 +1,21 @@ +# passerelle - uniform access to multiple data sources and services +# Copyright (C) 2020 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . import isodate import json +import logging import mock import pytest from requests import RequestException @@ -7,9 +23,11 @@ from requests import RequestException from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.urls import reverse +from django.utils.translation import ugettext as _ +from passerelle.apps.choosit.models import ChoositSMSGateway from passerelle.apps.ovh.models import OVHSMSGateway -from passerelle.base.models import ApiUser, AccessRight, Job +from passerelle.base.models import ApiUser, AccessRight, Job, ResourceLog from passerelle.sms.models import SMSResource, SMSLog from passerelle.utils.jsonresponse import APIError @@ -37,6 +55,70 @@ def test_clean_numbers(): connector.clean_numbers(['0123']) +def test_authorize_numbers(): + connector = OVHSMSGateway() + + # premium-rate + assert connector.allow_premium_rate == False + number = '0033' + '8' + '12345678' + with pytest.raises(APIError, match='no phone number was authorized: %s' % number): + connector.authorize_numbers([number]) + connector.allow_premium_rate = True + connector.save() + assert connector.authorize_numbers([number])[0] == [number] + + # All country + assert connector.authorized == [SMSResource.ALL] + number = '0033' + '1' + '12345678' + assert connector.authorize_numbers([number])[0] == [number] + connector.authorized = [SMSResource.FR_METRO] + connector.save() + with pytest.raises(APIError, match='no phone number was authorized: %s' % number): + connector.authorize_numbers([number]) + + # France + number = '0033' + '6' + '12345678' + assert connector.authorize_numbers([number])[0] == [number] + connector.authorized = [SMSResource.FR_DOMTOM] + connector.save() + with pytest.raises(APIError, match='no phone number was authorized: %s' % number): + connector.authorize_numbers([number]) + + # Dom-Tom + number = '596596' + '123456' + assert connector.authorize_numbers([number])[0] == [number] + connector.authorized = [SMSResource.BE_] + connector.save() + with pytest.raises(APIError, match='no phone number was authorized: %s' % number): + connector.authorize_numbers([number]) + + # Belgian + number = '0032' + '45' + '1234567' + assert connector.authorize_numbers([number])[0] == [number] + connector.authorized = [SMSResource.FR_METRO] + connector.save() + with pytest.raises(APIError, match='no phone number was authorized: %s' % number): + connector.authorize_numbers([number]) + + # Don't raise if authorized destinations are not empty + connector.allow_premium_rate = False + connector.authorized = [SMSResource.FR_METRO] + connector.save() + numbers = [ + '0033' + '8' + '12345678', + '0033' + '1' + '12345678', + '0033' + '6' + '12345678', + '596596' + '123456', + '0032' + '45' + '1234567', + ] + authorized_numbers, warnings = connector.authorize_numbers(numbers) + assert authorized_numbers == ['0033612345678'] + assert warnings == { + 'deny premium rate phone numbers': '0033812345678', + 'deny foreign phone numbers': '0032451234567, 0033112345678, 596596123456', + } + + @pytest.fixture(params=klasses) def connector(request, db): klass = request.param @@ -420,3 +502,69 @@ def test_ovh_token_request(admin_user, app): assert 'Successfuly completed connector configuration' in resp.text connector.refresh_from_db() assert connector.consumer_key == 'xyz' + + +@pytest.mark.parametrize('connector', [ChoositSMSGateway], indirect=True) +def test_manager(admin_user, app, connector): + app = login(app) + path = '/%s/%s/' % (connector.get_connector_slug(), connector.slug) + resp = app.get(path) + assert ( + '33' + in [ + x.text + for x in resp.html.find('div', {'id': 'description'}).find_all('p') + if x.text.startswith(_('Default country code')) + ][0] + ) + assert ( + _('All') + in [x.text for x in resp.html.find_all('p') if x.text.startswith(_('Authorized Countries'))][0] + ) + assert ( + _('no') + in [x.text for x in resp.html.find_all('p') if x.text.startswith(_('Allow premium rate numbers'))][0] + ) + + path = '/manage/%s/%s/edit' % (connector.get_connector_slug(), connector.slug) + resp = app.get(path) + resp.form['authorized'] = [] + resp = resp.form.submit() + assert resp.html.find('div', {'class': 'errornotice'}).p.text == 'There were errors processing your form.' + assert resp.html.find('div', {'class': 'error'}).text.strip() == 'This field is required.' + resp.form['authorized'] = [SMSResource.FR_METRO, SMSResource.FR_DOMTOM] + resp = resp.form.submit() + resp = resp.follow() + assert ( + _('France mainland (+33 [67])') + in [x.text for x in resp.html.find_all('p') if x.text.startswith(_('Authorized Countries'))][0] + ) + + path = '/%s/%s/send/' % (connector.get_connector_slug(), connector.slug) + payload = { + 'message': 'plop', + 'from': '+33699999999', + 'to': ['+33688888888'], + } + resp = app.post_json(path, params=payload) + assert resp.json['warn'] == { + 'deny premium rate phone numbers': '', + 'deny foreign phone numbers': '', + } + with mock.patch.object(type(connector), 'send_msg') as send_function: + send_function.return_value = {} + connector.jobs() + assert SMSLog.objects.count() == 1 + + payload['to'][0] = '+33188888888' + SMSLog.objects.all().delete() + app.post_json(path, params=payload) + with mock.patch.object(type(connector), 'send_msg') as send_function: + send_function.return_value = {} + connector.jobs() + assert not SMSLog.objects.count() + assert ResourceLog.objects.filter(levelno=logging.WARNING).count() == 1 + assert ( + ResourceLog.objects.filter(levelno=30)[0].extra['exception'] + == 'no phone number was authorized: 0033188888888' + )