passerelle/passerelle/sms/models.py

260 lines
9.8 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.conf.urls import url
from django.contrib.postgres.fields import ArrayField
from django.core.validators import RegexValidator
from django.db import models
from django.urls import reverse
from django.utils.module_loading import import_string
from django.utils.translation import ugettext_lazy as _
from passerelle.base.models import BaseResource
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]+$'},
},
},
}
def authorized_default():
return [SMSResource.ALL]
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='33',
validators=[RegexValidator('^[0-9]*$', _('The country must only contain numbers'))],
)
default_trunk_prefix = models.CharField(
verbose_name=_('Default trunk prefix'),
max_length=2,
default='0',
validators=[RegexValidator('^[0-9]*$', _('The trunk prefix must only contain numbers'))],
) # Yeah France first !
max_message_length = models.IntegerField(
_('Maximum message length'), help_text=_('Messages over this limit will be truncated.'), default=2000
)
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=authorized_default,
)
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')
@classmethod
def get_statistics_urls(cls):
from .views import SmsStatisticsView
statistics_urlpatterns = [
url(
r'^(?P<slug>[\w,-]+)/sms-count/$',
SmsStatisticsView.as_view(),
name='api-statistics-sms-%s' % cls.get_connector_slug(),
),
]
return statistics_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().__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):
credits_spent = self.send_msg(**kwargs)
SMSLog.objects.create(appname=self.get_connector_slug(), slug=self.slug, credits=credits_spent)
return {'status_info': {'credits-spent': credits_spent}}
def get_statistics_entries(self, request):
return [
{
'name': _('SMS Count (%s)') % self.slug,
'url': request.build_absolute_uri(
reverse('api-statistics-sms-%s' % self.get_connector_slug(), kwargs={'slug': self.slug}),
),
'id': 'sms_count_%s_%s' % (self.get_connector_slug(), self.slug),
'filters': [
{
'id': 'time_interval',
'label': _('Interval'),
'options': [{'id': 'day', 'label': _('Day')}],
'required': True,
'default': 'day',
},
],
}
]
class Meta:
abstract = True
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)
credits = models.PositiveSmallIntegerField(null=True)
def __str__(self):
return '%s %s %s' % (self.timestamp, self.appname, self.slug)