passerelle/passerelle/sms/models.py

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)