From 333ed47b95b15f5b1c5044e8fc6f633839d0ebc9 Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Sat, 2 May 2020 21:34:05 +0200
Subject: [PATCH 1/2] sms: add number authorization masks to SMSResource
 (#39650)

---
 .../migrations/0010_auto_20200720_1007.py     | 26 ++++++
 .../migrations/0009_auto_20200720_1007.py     | 26 ++++++
 .../migrations/0009_auto_20200720_1007.py     | 26 ++++++
 .../ovh/migrations/0009_auto_20200720_1007.py | 26 ++++++
 .../migrations/0009_auto_20200720_1007.py     | 26 ++++++
 .../migrations/0002_auto_20200720_1007.py     | 26 ++++++
 passerelle/sms/forms.py                       | 38 +++++++++
 passerelle/sms/models.py                      | 79 ++++++++++++++++++-
 tests/test_sms.py                             | 47 +++++++++++
 9 files changed, 319 insertions(+), 1 deletion(-)
 create mode 100644 passerelle/apps/choosit/migrations/0010_auto_20200720_1007.py
 create mode 100644 passerelle/apps/mobyt/migrations/0009_auto_20200720_1007.py
 create mode 100644 passerelle/apps/orange/migrations/0009_auto_20200720_1007.py
 create mode 100644 passerelle/apps/ovh/migrations/0009_auto_20200720_1007.py
 create mode 100644 passerelle/apps/oxyd/migrations/0009_auto_20200720_1007.py
 create mode 100644 passerelle/apps/twilio/migrations/0002_auto_20200720_1007.py
 create mode 100644 passerelle/sms/forms.py

diff --git a/passerelle/apps/choosit/migrations/0010_auto_20200720_1007.py b/passerelle/apps/choosit/migrations/0010_auto_20200720_1007.py
new file mode 100644
index 00000000..775f3f55
--- /dev/null
+++ b/passerelle/apps/choosit/migrations/0010_auto_20200720_1007.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-07-20 08:07
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import passerelle.sms.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('choosit', '0009_choositsmsgateway_max_message_length'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='choositsmsgateway',
+            name='authorized',
+            field=passerelle.sms.models.SMSMultipleChoiceField(choices=[('fr-metro', 'France mainland (+33 [67])'), ('fr-domtom', 'France DOM/TOM (+262, etc.)'), ('be', 'Belgian (+32 4[5-9]) '), ('all', 'All')], default=['all'], max_length=128, null=True, verbose_name='Authorized Countries'),
+        ),
+        migrations.AddField(
+            model_name='choositsmsgateway',
+            name='premium_rate',
+            field=models.CharField(choices=[('no', 'Do no allow'), ('yes', 'Allow')], default='no', help_text='This option is only applyed to France mainland', max_length=32, verbose_name='Premium rate numbers'),
+        ),
+    ]
diff --git a/passerelle/apps/mobyt/migrations/0009_auto_20200720_1007.py b/passerelle/apps/mobyt/migrations/0009_auto_20200720_1007.py
new file mode 100644
index 00000000..5339d353
--- /dev/null
+++ b/passerelle/apps/mobyt/migrations/0009_auto_20200720_1007.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-07-20 08:07
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import passerelle.sms.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('mobyt', '0008_auto_20200310_1539'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='mobytsmsgateway',
+            name='authorized',
+            field=passerelle.sms.models.SMSMultipleChoiceField(choices=[('fr-metro', 'France mainland (+33 [67])'), ('fr-domtom', 'France DOM/TOM (+262, etc.)'), ('be', 'Belgian (+32 4[5-9]) '), ('all', 'All')], default=['all'], max_length=128, null=True, verbose_name='Authorized Countries'),
+        ),
+        migrations.AddField(
+            model_name='mobytsmsgateway',
+            name='premium_rate',
+            field=models.CharField(choices=[('no', 'Do no allow'), ('yes', 'Allow')], default='no', help_text='This option is only applyed to France mainland', max_length=32, verbose_name='Premium rate numbers'),
+        ),
+    ]
diff --git a/passerelle/apps/orange/migrations/0009_auto_20200720_1007.py b/passerelle/apps/orange/migrations/0009_auto_20200720_1007.py
new file mode 100644
index 00000000..7a59f058
--- /dev/null
+++ b/passerelle/apps/orange/migrations/0009_auto_20200720_1007.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-07-20 08:07
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import passerelle.sms.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('orange', '0008_auto_20200412_1240'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='orangesmsgateway',
+            name='authorized',
+            field=passerelle.sms.models.SMSMultipleChoiceField(choices=[('fr-metro', 'France mainland (+33 [67])'), ('fr-domtom', 'France DOM/TOM (+262, etc.)'), ('be', 'Belgian (+32 4[5-9]) '), ('all', 'All')], default=['all'], max_length=128, null=True, verbose_name='Authorized Countries'),
+        ),
+        migrations.AddField(
+            model_name='orangesmsgateway',
+            name='premium_rate',
+            field=models.CharField(choices=[('no', 'Do no allow'), ('yes', 'Allow')], default='no', help_text='This option is only applyed to France mainland', max_length=32, verbose_name='Premium rate numbers'),
+        ),
+    ]
diff --git a/passerelle/apps/ovh/migrations/0009_auto_20200720_1007.py b/passerelle/apps/ovh/migrations/0009_auto_20200720_1007.py
new file mode 100644
index 00000000..7a36191f
--- /dev/null
+++ b/passerelle/apps/ovh/migrations/0009_auto_20200720_1007.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-07-20 08:07
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import passerelle.sms.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('ovh', '0008_ovhsmsgateway_max_message_length'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='ovhsmsgateway',
+            name='authorized',
+            field=passerelle.sms.models.SMSMultipleChoiceField(choices=[('fr-metro', 'France mainland (+33 [67])'), ('fr-domtom', 'France DOM/TOM (+262, etc.)'), ('be', 'Belgian (+32 4[5-9]) '), ('all', 'All')], default=['all'], max_length=128, null=True, verbose_name='Authorized Countries'),
+        ),
+        migrations.AddField(
+            model_name='ovhsmsgateway',
+            name='premium_rate',
+            field=models.CharField(choices=[('no', 'Do no allow'), ('yes', 'Allow')], default='no', help_text='This option is only applyed to France mainland', max_length=32, verbose_name='Premium rate numbers'),
+        ),
+    ]
diff --git a/passerelle/apps/oxyd/migrations/0009_auto_20200720_1007.py b/passerelle/apps/oxyd/migrations/0009_auto_20200720_1007.py
new file mode 100644
index 00000000..a6100f04
--- /dev/null
+++ b/passerelle/apps/oxyd/migrations/0009_auto_20200720_1007.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-07-20 08:07
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import passerelle.sms.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('oxyd', '0008_oxydsmsgateway_max_message_length'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='oxydsmsgateway',
+            name='authorized',
+            field=passerelle.sms.models.SMSMultipleChoiceField(choices=[('fr-metro', 'France mainland (+33 [67])'), ('fr-domtom', 'France DOM/TOM (+262, etc.)'), ('be', 'Belgian (+32 4[5-9]) '), ('all', 'All')], default=['all'], max_length=128, null=True, verbose_name='Authorized Countries'),
+        ),
+        migrations.AddField(
+            model_name='oxydsmsgateway',
+            name='premium_rate',
+            field=models.CharField(choices=[('no', 'Do no allow'), ('yes', 'Allow')], default='no', help_text='This option is only applyed to France mainland', max_length=32, verbose_name='Premium rate numbers'),
+        ),
+    ]
diff --git a/passerelle/apps/twilio/migrations/0002_auto_20200720_1007.py b/passerelle/apps/twilio/migrations/0002_auto_20200720_1007.py
new file mode 100644
index 00000000..08645d57
--- /dev/null
+++ b/passerelle/apps/twilio/migrations/0002_auto_20200720_1007.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-07-20 08:07
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import passerelle.sms.models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('twilio', '0001_initial'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='twiliosmsgateway',
+            name='authorized',
+            field=passerelle.sms.models.SMSMultipleChoiceField(choices=[('fr-metro', 'France mainland (+33 [67])'), ('fr-domtom', 'France DOM/TOM (+262, etc.)'), ('be', 'Belgian (+32 4[5-9]) '), ('all', 'All')], default=['all'], max_length=128, null=True, verbose_name='Authorized Countries'),
+        ),
+        migrations.AddField(
+            model_name='twiliosmsgateway',
+            name='premium_rate',
+            field=models.CharField(choices=[('no', 'Do no allow'), ('yes', 'Allow')], default='no', help_text='This option is only applyed to France mainland', max_length=32, verbose_name='Premium rate numbers'),
+        ),
+    ]
diff --git a/passerelle/sms/forms.py b/passerelle/sms/forms.py
new file mode 100644
index 00000000..81d887ca
--- /dev/null
+++ b/passerelle/sms/forms.py
@@ -0,0 +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 <http://www.gnu.org/licenses/>.
+from django import forms
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.forms import GenericConnectorForm
+
+
+class SMSConnectorForm(GenericConnectorForm):
+    class Meta:
+        fields = '__all__'
+        widgets = {
+            'premium_rate': forms.RadioSelect,
+        }
+
+    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 7651d98d..eac0f365 100644
--- a/passerelle/sms/models.py
+++ b/passerelle/sms/models.py
@@ -11,39 +11,116 @@
 # 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 <http://www.gnu.org/licenses/>.
 import logging
 import re
 
+from django.core.exceptions import ValidationError
 from django.db import models
+from django.forms.models import modelform_factory
 from django.utils import six
 from django.utils.translation import ugettext_lazy as _
+from django.forms.widgets import ClearableFileInput
 
 from passerelle.base.models import BaseResource
 from passerelle.compat import json_loads
 from passerelle.utils.api import endpoint
 from passerelle.utils.jsonresponse import APIError
+from passerelle.sms.forms import SMSConnectorForm
+
+
+def parse_choice(choice_string):
+    if choice_string is None:
+        return []
+    return choice_string[1:-1].split(',')
+
+
+class SMSMultipleChoiceField(models.TextField):
+    def from_db_value(self, value, *args, **kwargs):
+        if value is None:
+            return value
+        return parse_choice(value)
+
+    def to_python(self, value):
+        if isinstance(value, list):
+            return value
+        if value is None:
+            return value
+        return parse_choice(value)
+
+    def validate(self, value, model_instance):
+        if not self.editable:
+            # Skip validation for non-editable fields.
+            return
+
+        for key in value:
+            for option_key, option_value in self.choices:
+                if key == option_key:
+                    break
+            else:
+                raise ValidationError(
+                    self.error_messages['invalid_choice'],
+                    code='invalid_choice',
+                    params={'value': value},
+                )
 
 
 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/'
 
     _can_send_messages_description = _('Sending messages is limited to the following API users:')
 
     default_country_code = models.CharField(verbose_name=_('Default country code'), max_length=3,
                                             default=u'33')
     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)
 
+    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 = SMSMultipleChoiceField(
+        _('Authorized Countries'),
+        max_length=128, null=True, choices=AUTHORIZED, default=[ALL])
+
+    NO_ = 'no'
+    YES = 'yes'
+    PREMIUM_RATE = [
+        (NO_, _('Do no allow')),
+        (YES, _('Allow')),
+    ]
+    premium_rate = models.CharField(
+        _('Premium rate numbers'),
+        max_length=32, choices=PREMIUM_RATE, default=NO_,
+        help_text=_('This option is only applyed to France mainland')
+    )
+
+    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:
             # most gateways needs the number prefixed by the country code, this is
             # really unfortunate.
             dest = dest.strip()
             number = ''.join(re.findall('[0-9]', dest))
             if dest.startswith('+'):
diff --git a/tests/test_sms.py b/tests/test_sms.py
index 7deea10a..adf4b9d7 100644
--- a/tests/test_sms.py
+++ b/tests/test_sms.py
@@ -1,15 +1,32 @@
+# 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 <http://www.gnu.org/licenses/>.
 import isodate
 import mock
 import pytest
 from requests import RequestException
 
 from django.contrib.contenttypes.models import ContentType
+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.sms.models import SMSResource, SMSLog
 from passerelle.utils.jsonresponse import APIError
 
 from test_manager import login, admin_user
 
 import utils
@@ -133,8 +150,38 @@ def test_sms_log(app, connector):
         'from': '+33699999999',
         'to': ['+33688888888'],
     }
     with mock.patch.object(OVHSMSGateway, 'send_msg') as send_function:
         send_function.return_value = {}
         result = app.post_json(path, params=payload)
         connector.jobs()
         assert SMSLog.objects.filter(appname=connector.get_connector_slug(), slug=connector.slug).exists()
+
+
+@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 _('Do no allow') in [
+        x.text for x in resp.html.find_all('p')
+        if x.text.startswith(_('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.'
+    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]
-- 
2.27.0

