From ed40c6bee2d6b0c88e029331ef7d569f4951de3f 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_20200729_1105.py | 35 +++++++++ passerelle/apps/ovh/models.py | 77 ++++++++++++++++++- tests/test_sms.py | 44 +++++++++++ tests/utils.py | 1 + 4 files changed, 155 insertions(+), 2 deletions(-) create mode 100644 passerelle/apps/ovh/migrations/0009_auto_20200729_1105.py diff --git a/passerelle/apps/ovh/migrations/0009_auto_20200729_1105.py b/passerelle/apps/ovh/migrations/0009_auto_20200729_1105.py new file mode 100644 index 00000000..b68c410c --- /dev/null +++ b/passerelle/apps/ovh/migrations/0009_auto_20200729_1105.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2020-07-29 09:05 +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, max_length=16, verbose_name='Application key'), + ), + migrations.AddField( + model_name='ovhsmsgateway', + name='application_secret', + field=models.CharField(blank=True, max_length=32, verbose_name='Application secret'), + ), + migrations.AddField( + model_name='ovhsmsgateway', + name='consumer_key', + field=models.CharField(blank=True, max_length=32, verbose_name='Consumer key'), + ), + 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)'), + ), + ] diff --git a/passerelle/apps/ovh/models.py b/passerelle/apps/ovh/models.py index a690c330..4f316350 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,7 @@ from passerelle.utils.jsonresponse import APIError class OVHSMSGateway(SMSResource): + 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 +25,16 @@ class OVHSMSGateway(SMSResource): (3, _('Messages are stored in external storage like a PDA or ' 'a PC.')), ) + NEW_MESSAGES_CLASSES = ['flash', 'phoneDisplay', 'sim', 'toolkit'] + account = models.CharField(verbose_name=_('Account'), max_length=64) + application_key = models.CharField(verbose_name=_('Application key'), max_length=16, blank=True) + application_secret = models.CharField(verbose_name=_('Application secret'), max_length=32, blank=True) + consumer_key = models.CharField(verbose_name=_('Consumer key'), max_length=32, blank=True) username = models.CharField(verbose_name=_('Username'), max_length=64) - password = models.CharField(verbose_name=_('Password'), max_length=64) + 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'), @@ -75,8 +86,70 @@ class OVHSMSGateway(SMSResource): verbose_name = 'OVH' 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