From 2017980938bc2ad30bc6a9cb77db936abd7733f2 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 29 Jul 2020 10:32:41 +0200 Subject: [PATCH] ovh: support /jobs/ API endpoint (#44313) --- .../ovh/migrations/0009_auto_20200730_1047.py | 45 +++++++ passerelle/apps/ovh/models.py | 110 +++++++++++++++++- tests/test_sms.py | 44 +++++++ tests/utils.py | 1 + 4 files changed, 196 insertions(+), 4 deletions(-) create mode 100644 passerelle/apps/ovh/migrations/0009_auto_20200730_1047.py diff --git a/passerelle/apps/ovh/migrations/0009_auto_20200730_1047.py b/passerelle/apps/ovh/migrations/0009_auto_20200730_1047.py new file mode 100644 index 00000000..4f3d597d --- /dev/null +++ b/passerelle/apps/ovh/migrations/0009_auto_20200730_1047.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-07-30 08:47 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('ovh', '0008_ovhsmsgateway_max_message_length'), + ] + + operations = [ + migrations.AddField( + model_name='ovhsmsgateway', + name='application_key', + field=models.CharField(blank=True, help_text='Random token obtained from OVH.', max_length=16, verbose_name='Application key'), + ), + migrations.AddField( + model_name='ovhsmsgateway', + name='application_secret', + field=models.CharField(blank=True, help_text='Obtained at the same time as "Application key".', max_length=32, verbose_name='Application secret'), + ), + migrations.AddField( + model_name='ovhsmsgateway', + name='consumer_key', + field=models.CharField(blank=True, help_text='Obtained at the same time as "Application key".', max_length=32, verbose_name='Consumer key'), + ), + migrations.AlterField( + model_name='ovhsmsgateway', + name='account', + field=models.CharField(help_text='Account identifier, such as sms-XXXXXX-1.', max_length=64, verbose_name='Account'), + ), + migrations.AlterField( + model_name='ovhsmsgateway', + name='password', + field=models.CharField(blank=True, help_text='Password for legacy API. This field is obsolete once keys and secret fields below are filled.', max_length=64, verbose_name='Password (deprecated)'), + ), + migrations.AlterField( + model_name='ovhsmsgateway', + name='username', + field=models.CharField(help_text='API user created on the SMS account.', max_length=64, verbose_name='Username'), + ), + ] diff --git a/passerelle/apps/ovh/models.py b/passerelle/apps/ovh/models.py index a690c330..6a2f70a5 100644 --- a/passerelle/apps/ovh/models.py +++ b/passerelle/apps/ovh/models.py @@ -1,4 +1,7 @@ +import hashlib +import json import requests +import time from django.db import models from django.utils.encoding import force_text @@ -9,6 +12,8 @@ from passerelle.utils.jsonresponse import APIError class OVHSMSGateway(SMSResource): + documentation_url = 'https://doc-publik.entrouvert.com/admin-fonctionnel/les-tutos/configuration-envoi-sms/' + API_URL = 'https://eu.api.ovh.com/1.0/sms/%(serviceName)s/users/%(login)s/jobs/' URL = 'https://www.ovh.com/cgi-bin/sms/http2sms.cgi' MESSAGES_CLASSES = ( (0, _('Message are directly shown to users on phone screen ' @@ -21,9 +26,44 @@ class OVHSMSGateway(SMSResource): (3, _('Messages are stored in external storage like a PDA or ' 'a PC.')), ) - account = models.CharField(verbose_name=_('Account'), max_length=64) - username = models.CharField(verbose_name=_('Username'), max_length=64) - password = models.CharField(verbose_name=_('Password'), max_length=64) + NEW_MESSAGES_CLASSES = ['flash', 'phoneDisplay', 'sim', 'toolkit'] + + account = models.CharField( + verbose_name=_('Account'), max_length=64, help_text=_('Account identifier, such as sms-XXXXXX-1.') + ) + + application_key = models.CharField( + verbose_name=_('Application key'), + max_length=16, + blank=True, + help_text=_('Random token obtained from OVH.'), + ) + application_secret = models.CharField( + verbose_name=_('Application secret'), + max_length=32, + blank=True, + help_text=_('Obtained at the same time as "Application key".'), + ) + consumer_key = models.CharField( + verbose_name=_('Consumer key'), + max_length=32, + blank=True, + help_text=_('Obtained at the same time as "Application key".'), + ) + + username = models.CharField( + verbose_name=_('Username'), + max_length=64, + help_text=_('API user created on the SMS account.'), + ) + password = models.CharField( + verbose_name=_('Password (deprecated)'), + max_length=64, + blank=True, + help_text=_( + 'Password for legacy API. This field is obsolete once keys and secret fields below are filled.' + ), + ) msg_class = models.IntegerField(choices=MESSAGES_CLASSES, default=1, verbose_name=_('Message class')) credit_threshold_alert = models.PositiveIntegerField(verbose_name=_('Credit alert threshold'), @@ -76,7 +116,69 @@ class OVHSMSGateway(SMSResource): db_table = 'sms_ovh' def send_msg(self, text, sender, destinations, **kwargs): - """Send a SMS using the OVH provider""" + if not (self.application_key and self.consumer_key and self.application_secret): + return self.send_msg_legacy(text, sender, destinations, **kwargs) + + url = self.API_URL % {'serviceName': self.account, 'login': self.username} + body = { + 'sender': sender, + 'receivers': destinations, + 'message': text, + 'class': self.NEW_MESSAGES_CLASSES[self.msg_class], + } + if not kwargs['stop']: + body.update({'noStopClause': 1}) + + # sign request + now = str(int(time.time())) + signature = hashlib.sha1() + to_sign = "+".join((self.application_secret, self.consumer_key, 'POST', url, json.dumps(body), now)) + signature.update(to_sign.encode()) + + headers = { + 'X-Ovh-Application': self.application_key, + 'X-Ovh-Consumer': self.consumer_key, + 'X-Ovh-Timestamp': now, + 'X-Ovh-Signature': "$1$" + signature.hexdigest(), + } + + try: + response = self.requests.post(url, headers=headers, json=body) + except requests.RequestException as e: + raise APIError('OVH error: POST failed, %s' % e) + else: + try: + result = response.json() + except ValueError as e: + raise APIError('OVH error: bad JSON response') + try: + response.raise_for_status() + except requests.RequestException as e: + raise APIError('OVH error: %s "%s"' % (e, result)) + + ret = {} + credits_removed = result['totalCreditsRemoved'] + # update credit left + self.credit_left -= credits_removed + if self.credit_left < 0: + self.credit_left = 0 + self.save() + if self.credit_left < self.credit_threshold_alert: + ret['warning'] = 'credit level too low for %s: %s (threshold %s)' % ( + self.slug, + self.credit_left, + self.credit_threshold_alert, + ) + ret['credit_left'] = self.credit_left + ret['ovh_result'] = result + ret['sms_ids'] = result.get('ids', []) + + return ret + + def send_msg_legacy(self, text, sender, destinations, **kwargs): + """Send a SMS using the HTTP2 endpoint""" + if not self.password: + raise APIError('Improperly configured, empty keys or password fields.') text = force_text(text).encode('utf-8') to = ','.join(destinations) diff --git a/tests/test_sms.py b/tests/test_sms.py index 7deea10a..6527110c 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -138,3 +138,47 @@ def test_sms_log(app, connector): result = app.post_json(path, params=payload) connector.jobs() assert SMSLog.objects.filter(appname=connector.get_connector_slug(), slug=connector.slug).exists() + + +def test_ovh_new_api(app, freezer): + connector = OVHSMSGateway.objects.create( + slug='ovh', account='sms-test42', username='john', + application_key='RHrTdU2oTsrVC0pu', + application_secret='CLjtS69tTcPgCKxedeoZlgMSoQGSiXMa', + consumer_key='iF0zi0MJrbjNcI3hvuvwkhNk8skrigxz' + ) + api = ApiUser.objects.create(username='apiuser') + obj_type = ContentType.objects.get_for_model(connector) + # no access check + AccessRight.objects.create(codename='can_send_messages', apiuser=api, resource_type=obj_type, + resource_pk=connector.pk) + + payload = { + 'message': 'hello', + 'from': '+33699999999', + 'to': ['+33688888888', '+33677777777'], + } + + # register job + freezer.move_to('2019-01-01 00:00:00') + path = '/%s/%s/send/' % (connector.get_connector_slug(), connector.slug) + result = app.post_json(path, params=payload) + assert result.json['err'] == 0 + job_id = Job.objects.get(status='registered').id + + # perform job + freezer.move_to('2019-01-01 01:00:03') + resp = { + 'validReceivers': ['+33688888888', '+33677777777'], + 'totalCreditsRemoved': 1, + 'ids': [241615100], + 'invalidReceivers': [] + } + url = connector.API_URL % {'serviceName': 'sms-test42', 'login': 'john'} + with utils.mock_url(url, resp, 200) as mocked: + connector.jobs() + job = Job.objects.get(id=job_id) + assert job.status == 'completed' + + request = mocked.handlers[0].call['requests'][0] + assert 'X-Ovh-Signature' in request.headers diff --git a/tests/utils.py b/tests/utils.py index 6af91756..a8a46fd1 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -44,6 +44,7 @@ def mock_url(url=None, response='', status_code=200, headers=None, exception=Non if not isinstance(response, str): response = json.dumps(response) + @httmock.remember_called @httmock.urlmatch(**urlmatch_kwargs) def mocked(url, request): if exception: -- 2.20.1