214 lines
8.2 KiB
Python
214 lines
8.2 KiB
Python
# 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 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
|
|
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",
|
|
'required': ['message', 'from', 'to'],
|
|
'properties': {
|
|
'message': {
|
|
'description': 'String message',
|
|
'type': 'string',
|
|
},
|
|
'from': {
|
|
'description': 'Sender number',
|
|
'type': 'string',
|
|
},
|
|
'to': {
|
|
'description': 'Destination numbers',
|
|
"type": "array",
|
|
"items": {'type': 'string', 'pattern': r'^\+?[-.\s/\d]+$'},
|
|
},
|
|
},
|
|
}
|
|
|
|
|
|
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 !
|
|
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:
|
|
# 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('+'):
|
|
number = '00' + number
|
|
elif number.startswith('00'):
|
|
# assumes 00 is international access code, remove it
|
|
pass
|
|
elif number.startswith(self.default_trunk_prefix):
|
|
number = '00' + self.default_country_code + number[len(self.default_trunk_prefix) :]
|
|
else:
|
|
raise APIError(
|
|
'phone number %r is unsupported (no international prefix, '
|
|
'no local trunk prefix)' % number
|
|
)
|
|
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'],
|
|
description=_('Send a SMS message'),
|
|
parameters={'nostop': {'description': _('Do not send STOP instruction'), 'example_value': '1'}},
|
|
post={'request_body': {'schema': {'application/json': SEND_SCHEMA}}},
|
|
)
|
|
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(
|
|
'send_job',
|
|
text=post_data['message'],
|
|
sender=post_data['from'],
|
|
destinations=post_data['to'],
|
|
stop=stop,
|
|
)
|
|
return {'err': 0, 'warn': warnings}
|
|
|
|
def send_job(self, *args, **kwargs):
|
|
self.send_msg(**kwargs)
|
|
SMSLog.objects.create(appname=self.get_connector_slug(), slug=self.slug)
|
|
|
|
class Meta:
|
|
abstract = True
|
|
|
|
|
|
@six.python_2_unicode_compatible
|
|
class SMSLog(models.Model):
|
|
timestamp = models.DateTimeField(auto_now_add=True)
|
|
appname = models.CharField(max_length=128, verbose_name='appname', null=True)
|
|
slug = models.CharField(max_length=128, verbose_name='slug', null=True)
|
|
|
|
def __str__(self):
|
|
return '%s %s %s' % (self.timestamp, self.appname, self.slug)
|