From a00ee4f92a73225a1fc2597725b1d7f1c6f5c91e Mon Sep 17 00:00:00 2001
From: Nicolas ROCHE <nroche@entrouvert.com>
Date: Mon, 30 Mar 2020 14:38:42 +0200
Subject: [PATCH] orange_sms: initial version (#41092)

copied from https://github.com/departement-loire-atlantique/passerelle-orangesms
---
 passerelle/apps/orange_sms/__init__.py        |   0
 .../orange_sms/migrations/0001_initial.py     |  37 ++++
 .../apps/orange_sms/migrations/__init__.py    |   0
 passerelle/apps/orange_sms/models.py          | 118 ++++++++++++
 passerelle/settings.py                        |   1 +
 tests/test_orange_sms.py                      | 172 ++++++++++++++++++
 6 files changed, 328 insertions(+)
 create mode 100644 passerelle/apps/orange_sms/__init__.py
 create mode 100644 passerelle/apps/orange_sms/migrations/0001_initial.py
 create mode 100644 passerelle/apps/orange_sms/migrations/__init__.py
 create mode 100644 passerelle/apps/orange_sms/models.py
 create mode 100644 tests/test_orange_sms.py

diff --git a/passerelle/apps/orange_sms/__init__.py b/passerelle/apps/orange_sms/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/orange_sms/migrations/0001_initial.py b/passerelle/apps/orange_sms/migrations/0001_initial.py
new file mode 100644
index 00000000..42409a84
--- /dev/null
+++ b/passerelle/apps/orange_sms/migrations/0001_initial.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-04-04 11:32
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        ('base', '0018_smslog'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='OrangeRestSMSGateway',
+            fields=[
+                ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('title', models.CharField(max_length=50, verbose_name='Title')),
+                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+                ('description', models.TextField(verbose_name='Description')),
+                ('max_message_length', models.IntegerField(default=160, verbose_name='Maximum message length')),
+                ('username', models.CharField(max_length=64, verbose_name='Identifiant')),
+                ('password', models.CharField(max_length=64, verbose_name='Mot de passe')),
+                ('groupname', models.CharField(max_length=64, verbose_name='Groupe')),
+                ('default_country_code', models.CharField(default='33', max_length=3, verbose_name='Préfixe pays')),
+                ('default_trunk_prefix', models.CharField(default='0', max_length=2, verbose_name='Préfixe supprimé par défaut')),
+                ('users', models.ManyToManyField(blank=True, related_name='_orangerestsmsgateway_users_+', related_query_name='+', to='base.ApiUser')),
+            ],
+            options={
+                'verbose_name': 'Orange REST SMS',
+                'db_table': 'sms_orangerest',
+            },
+        ),
+    ]
diff --git a/passerelle/apps/orange_sms/migrations/__init__.py b/passerelle/apps/orange_sms/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/orange_sms/models.py b/passerelle/apps/orange_sms/models.py
new file mode 100644
index 00000000..e74fab01
--- /dev/null
+++ b/passerelle/apps/orange_sms/models.py
@@ -0,0 +1,118 @@
+# -*- coding: utf-8 -*-
+# passerelle - uniform access to multiple data sources and services
+#
+# MIT License
+# Copyright (c) 2020  departement-loire-atlantique
+#
+# GNU Affero General Public License
+# 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 json
+
+from django.db import models
+from django.utils.translation import ugettext_lazy as _
+
+from passerelle.base.models import SMSResource
+from passerelle.utils.jsonresponse import APIError
+
+BASE_API = 'https://contact-everyone.orange-business.com/api/v1.2/'
+URL_TOKEN = BASE_API + 'oauth/token'
+URL_GROUPS = BASE_API + 'groups'
+URL_DIFFUSION = BASE_API + 'groups/%s/diffusion-requests'
+
+
+class OrangeError(APIError):
+    pass
+
+
+def get_json(response):
+    try:
+        return response.json()
+    except ValueError:
+        raise OrangeError('Orange returned Invalid JSON content: %s' % response.text)
+
+
+class OrangeRestSMSGateway(SMSResource):
+    username = models.CharField(verbose_name=_('Identifiant'), max_length=64)
+    password = models.CharField(verbose_name=_('Mot de passe'), max_length=64)
+    groupname = models.CharField(verbose_name=_('Groupe'), max_length=64)
+
+    default_country_code = models.CharField(
+        verbose_name='Préfixe pays', max_length=3, default='33')
+    default_trunk_prefix = models.CharField(
+        verbose_name='Préfixe supprimé par défaut', max_length=2, default='0')
+
+    manager_view_template_name = 'passerelle/manage/messages_service_view.html'
+
+    class Meta:
+        verbose_name = 'Orange REST'
+        db_table = 'sms_orangerest'
+
+    def get_access_token(self):
+        headers = {'content-type': 'application/x-www-form-urlencoded'}
+        params = {'username': self.username, 'password': self.password}
+        response = self.requests.post(URL_TOKEN, data=params, headers=headers)
+        if response.status_code != 200:
+            raise APIError('Bad username or password: %s, %s' % (
+                response.status_code, response.text))
+        response_json = get_json(response)
+        if 'access_token' not in response_json:
+            raise OrangeError('Orange do not return access token')
+        return response_json['access_token']
+
+    def group_id_from_name(self, access_token):
+        headers = {'authorization': 'Bearer %s' % access_token}
+        response = self.requests.get(URL_GROUPS, headers=headers)
+        if response.status_code != 200:
+            raise APIError('Bad token: %s, %s' % (
+                response.status_code, response.text))
+        response_json = get_json(response)
+        group_id = None
+        for group in response_json:
+            if group['name'] == self.groupname:
+                group_id = group['id']
+                break
+        if not group_id:
+            raise APIError('Group name not found: ' + self.groupname)
+        return group_id
+
+    def diffusion(self, access_token, group_id, destinations, message):
+        headers = {
+            'content-type': 'application/json',
+            'authorization': 'Bearer %s' % access_token,
+        }
+        payload = json.dumps({
+            'name': 'Send a SMS from passerelle',
+            'msisdns': destinations,
+            'smsParam': {
+                'encoding': 'GSM7',
+                'body': message
+            }
+        })
+        response = self.requests.post(
+            URL_DIFFUSION % group_id, data=payload, headers=headers)
+        if response.status_code != 201:
+            raise OrangeError('Orange fails to send SMS: %s, %s' % (
+                response.status_code, response.text))
+        return get_json(response)
+
+    def send_msg(self, text, sender, destinations, **kwargs):
+        '''Send a SMS using the Orange REST'''
+        destinations = self.clean_numbers(
+            destinations, self.default_country_code, self.default_trunk_prefix)
+        access_token = self.get_access_token()
+        group_id = self.group_id_from_name(access_token)
+        response = self.diffusion(access_token, group_id, destinations, text)
+        return response
diff --git a/passerelle/settings.py b/passerelle/settings.py
index 04173804..43695f20 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -149,16 +149,17 @@ INSTALLED_APPS = (
     'passerelle.apps.opengis',
     'passerelle.apps.orange',
     'passerelle.apps.ovh',
     'passerelle.apps.oxyd',
     'passerelle.apps.pastell',
     'passerelle.apps.phonecalls',
     'passerelle.apps.solis',
     'passerelle.apps.vivaticket',
+    'passerelle.apps.orange_sms',
     # backoffice templates and static
     'gadjo',
 )
 
 # disable some applications for now
 PASSERELLE_APP_BDP_ENABLED = False
 PASSERELLE_APP_GDC_ENABLED = False
 PASSERELLE_APP_PASTELL_ENABLED = False
diff --git a/tests/test_orange_sms.py b/tests/test_orange_sms.py
new file mode 100644
index 00000000..ba4c117e
--- /dev/null
+++ b/tests/test_orange_sms.py
@@ -0,0 +1,172 @@
+# 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 json
+
+import httmock
+import pytest
+
+from django.contrib.contenttypes.models import ContentType
+
+from passerelle.apps.orange_sms.models import OrangeRestSMSGateway, OrangeError
+from passerelle.base.models import ApiUser, AccessRight
+from passerelle.utils.jsonresponse import APIError
+
+
+NETLOC = 'contact-everyone.orange-business.com'
+JSON_HEADERS = {'content-type': 'application/json'}
+PAYLOAD = {
+    'message': 'hello',
+    'from': '+33699999999',
+    'to': ['+33688888888', '+33677777777'],
+}
+
+
+@httmock.urlmatch(netloc=NETLOC, path='/api/v1.2/oauth/token', method='POST')
+def response_token_ok(url, request):
+    assert 'username=jdoe' in request.body
+    assert 'password=secret' in request.body
+    content = json.dumps({'access_token': 'my_token'})
+    return httmock.response(200, content, JSON_HEADERS)
+
+@httmock.urlmatch(netloc=NETLOC, path='/api/v1.2/groups', method='GET')
+def response_group_ok(url, request):
+    content = json.dumps([
+        {'name': 'group1', 'id': 'gid1'},
+        {'name': 'group2', 'id': 'gid2'},
+    ])
+    return httmock.response(200, content, JSON_HEADERS)
+
+@httmock.urlmatch(netloc=NETLOC, path='/api/v1.2/groups/gid2/diffusion-requests', method='POST')
+def response_diffusion_ok(url, request):
+    assert request.headers['authorization'] == 'Bearer my_token'
+    assert json.loads(request.body)['smsParam']['body'] == PAYLOAD['message']
+    '33688888888' in json.loads(request.body)['msisdns'][0]
+    '33677777777' in json.loads(request.body)['msisdns'][1]
+    content = json.dumps({'status': "I'm ok"})
+    return httmock.response(201, content, JSON_HEADERS)
+
+@httmock.urlmatch(netloc=NETLOC)
+def response_500(url, request):
+    return httmock.response(500, 'my_error')
+
+@httmock.urlmatch(netloc=NETLOC)
+def response_invalid_json(url, request):
+    return httmock.response(200, 'not a JSON content')
+
+def setup_access_rights(obj):
+    api = ApiUser.objects.create(username='all',
+                                 keytype='', key='')
+    obj_type = ContentType.objects.get_for_model(obj)
+    AccessRight.objects.create(codename='can_send_messages', apiuser=api,
+                               resource_type=obj_type, resource_pk=obj.pk)
+    return obj
+
+@pytest.fixture
+def connector(db):
+    return setup_access_rights(
+        OrangeRestSMSGateway.objects.create(
+            slug='my_connector',
+            username='jdoe',
+            password='secret',
+            groupname='group2'
+        ))
+
+
+def test_get_access_token(app, connector):
+    orange = OrangeRestSMSGateway()
+    orange.username = 'jdoe'
+    orange.password = 'secret'
+    with httmock.HTTMock(response_token_ok):
+        assert orange.get_access_token() == 'my_token'
+
+    # not 200
+    with pytest.raises(APIError, match='Bad username or password'):
+        with httmock.HTTMock(response_500):
+            orange.get_access_token()
+
+    # not json
+    with pytest.raises(OrangeError, match='Orange returned Invalid JSON content'):
+        with httmock.HTTMock(response_invalid_json):
+            orange.get_access_token()
+
+    # no token
+    @httmock.urlmatch(netloc=NETLOC, path='/api/v1.2/oauth/token', method='POST')
+    def mocked_response(url, request):
+        return httmock.response(200, '{}')
+
+    with pytest.raises(OrangeError, match='Orange do not return access token'):
+        with httmock.HTTMock(mocked_response):
+            orange.get_access_token()
+
+
+def test_group_id_from_name(app, connector):
+    orange = OrangeRestSMSGateway()
+    orange.groupname = 'group2'
+    with httmock.HTTMock(response_group_ok):
+        assert orange.group_id_from_name('my_token') == 'gid2'
+
+    # no group
+    orange.groupname = 'group3'
+    with pytest.raises(APIError, match='Group name not found: group3'):
+        with httmock.HTTMock(response_group_ok):
+            orange.group_id_from_name('my_token')
+
+    # not 200
+    orange.groupname = 'group2'
+    with pytest.raises(APIError, match='Bad token'):
+        with httmock.HTTMock(response_500):
+            orange.group_id_from_name('my_token')
+
+    # not json
+    with pytest.raises(OrangeError, match='Orange returned Invalid JSON content'):
+        with httmock.HTTMock(response_invalid_json):
+            orange.group_id_from_name('my_token')
+
+
+def test_diffusion(app, connector):
+    orange = OrangeRestSMSGateway()
+    with httmock.HTTMock(response_diffusion_ok):
+        resp = orange.diffusion('my_token', 'gid2', PAYLOAD['to'], PAYLOAD['message'])
+    assert resp['status'] == "I'm ok"
+
+    # not 201
+    with pytest.raises(OrangeError, match='Orange fails to send SMS'):
+        with httmock.HTTMock(response_500):
+            orange.diffusion('my_token', 'gid2', PAYLOAD['to'], PAYLOAD['message'])
+
+    # not json
+    @httmock.urlmatch(netloc=NETLOC)
+    def mocked_response(url, request):
+        return httmock.response(201, 'not a JSON content')
+
+    with pytest.raises(OrangeError, match='Orange returned Invalid JSON content'):
+        with httmock.HTTMock(mocked_response):
+            orange.diffusion('my_token', 'gid2', PAYLOAD['to'], PAYLOAD['message'])
+
+
+def test_send_msg(app, connector):
+    url = '/%s/%s/send/' % (connector.get_connector_slug(), connector.slug)
+    with httmock.HTTMock(response_token_ok, response_group_ok, response_diffusion_ok):
+        resp = app.post_json(url, params=PAYLOAD, status=200)
+    assert not resp.json['err']
+    assert resp.json['data']['status'] == "I'm ok"
+
+    # not 201
+    with httmock.HTTMock(response_token_ok, response_group_ok, response_500):
+        resp = app.post_json(url, params=PAYLOAD, status=200)
+    assert resp.json['err']
+    assert resp.json['err_desc'] == 'Orange fails to send SMS: 500, my_error'
-- 
2.25.1

