mirror of
https://github.com/GSA/notifications-api.git
synced 2026-02-01 23:55:58 -05:00
implement SNS
This commit is contained in:
@@ -34,8 +34,7 @@ from app.clients.cbc_proxy import CBCProxyClient
|
|||||||
from app.clients.document_download import DocumentDownloadClient
|
from app.clients.document_download import DocumentDownloadClient
|
||||||
from app.clients.email.aws_ses import AwsSesClient
|
from app.clients.email.aws_ses import AwsSesClient
|
||||||
from app.clients.email.aws_ses_stub import AwsSesStubClient
|
from app.clients.email.aws_ses_stub import AwsSesStubClient
|
||||||
from app.clients.sms.firetext import FiretextClient
|
from app.clients.sms.aws_sns import AwsSnsClient
|
||||||
from app.clients.sms.mmg import MMGClient
|
|
||||||
|
|
||||||
|
|
||||||
class SQLAlchemy(_SQLAlchemy):
|
class SQLAlchemy(_SQLAlchemy):
|
||||||
@@ -54,10 +53,9 @@ db = SQLAlchemy()
|
|||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
ma = Marshmallow()
|
ma = Marshmallow()
|
||||||
notify_celery = NotifyCelery()
|
notify_celery = NotifyCelery()
|
||||||
firetext_client = FiretextClient()
|
|
||||||
mmg_client = MMGClient()
|
|
||||||
aws_ses_client = AwsSesClient()
|
aws_ses_client = AwsSesClient()
|
||||||
aws_ses_stub_client = AwsSesStubClient()
|
aws_ses_stub_client = AwsSesStubClient()
|
||||||
|
aws_sns_client = AwsSnsClient()
|
||||||
encryption = Encryption()
|
encryption = Encryption()
|
||||||
zendesk_client = ZendeskClient()
|
zendesk_client = ZendeskClient()
|
||||||
statsd_client = StatsdClient()
|
statsd_client = StatsdClient()
|
||||||
@@ -96,8 +94,7 @@ def create_app(application):
|
|||||||
zendesk_client.init_app(application)
|
zendesk_client.init_app(application)
|
||||||
statsd_client.init_app(application)
|
statsd_client.init_app(application)
|
||||||
logging.init_app(application, statsd_client)
|
logging.init_app(application, statsd_client)
|
||||||
firetext_client.init_app(application, statsd_client=statsd_client)
|
aws_sns_client.init_app(application, statsd_client=statsd_client)
|
||||||
mmg_client.init_app(application, statsd_client=statsd_client)
|
|
||||||
|
|
||||||
aws_ses_client.init_app(application.config['AWS_REGION'], statsd_client=statsd_client)
|
aws_ses_client.init_app(application.config['AWS_REGION'], statsd_client=statsd_client)
|
||||||
aws_ses_stub_client.init_app(
|
aws_ses_stub_client.init_app(
|
||||||
@@ -108,7 +105,7 @@ def create_app(application):
|
|||||||
# If a stub url is provided for SES, then use the stub client rather than the real SES boto client
|
# If a stub url is provided for SES, then use the stub client rather than the real SES boto client
|
||||||
email_clients = [aws_ses_stub_client] if application.config['SES_STUB_URL'] else [aws_ses_client]
|
email_clients = [aws_ses_stub_client] if application.config['SES_STUB_URL'] else [aws_ses_client]
|
||||||
notification_provider_clients.init_app(
|
notification_provider_clients.init_app(
|
||||||
sms_clients=[firetext_client, mmg_client],
|
sms_clients=[aws_sns_client],
|
||||||
email_clients=email_clients
|
email_clients=email_clients
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ def requires_internal_auth(expected_client_id):
|
|||||||
client_id = _get_token_issuer(auth_token)
|
client_id = _get_token_issuer(auth_token)
|
||||||
|
|
||||||
if client_id != expected_client_id:
|
if client_id != expected_client_id:
|
||||||
|
current_app.logger.info('client_id: %s', client_id)
|
||||||
|
current_app.logger.info('expected_client_id: %s', expected_client_id)
|
||||||
raise AuthError("Unauthorized: not allowed to perform this action", 401)
|
raise AuthError("Unauthorized: not allowed to perform this action", 401)
|
||||||
|
|
||||||
api_keys = [
|
api_keys = [
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ from notifications_utils.template import SMSMessageTemplate
|
|||||||
|
|
||||||
from app import notify_celery, statsd_client
|
from app import notify_celery, statsd_client
|
||||||
from app.clients import ClientException
|
from app.clients import ClientException
|
||||||
from app.clients.sms.firetext import get_firetext_responses
|
|
||||||
from app.clients.sms.mmg import get_mmg_responses
|
|
||||||
from app.dao import notifications_dao
|
from app.dao import notifications_dao
|
||||||
from app.dao.templates_dao import dao_get_template_by_id
|
from app.dao.templates_dao import dao_get_template_by_id
|
||||||
from app.models import NOTIFICATION_PENDING
|
from app.models import NOTIFICATION_PENDING
|
||||||
@@ -15,44 +13,44 @@ from app.notifications.notifications_ses_callback import (
|
|||||||
check_and_queue_callback_task,
|
check_and_queue_callback_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
sms_response_mapper = {
|
# sms_response_mapper = {
|
||||||
'MMG': get_mmg_responses,
|
# 'MMG': get_mmg_responses,
|
||||||
'Firetext': get_firetext_responses,
|
# 'Firetext': get_firetext_responses,
|
||||||
}
|
# }
|
||||||
|
|
||||||
|
|
||||||
@notify_celery.task(bind=True, name="process-sms-client-response", max_retries=5, default_retry_delay=300)
|
# @notify_celery.task(bind=True, name="process-sms-client-response", max_retries=5, default_retry_delay=300)
|
||||||
def process_sms_client_response(self, status, provider_reference, client_name, detailed_status_code=None):
|
# def process_sms_client_response(self, status, provider_reference, client_name, detailed_status_code=None):
|
||||||
# validate reference
|
# # validate reference
|
||||||
try:
|
# try:
|
||||||
uuid.UUID(provider_reference, version=4)
|
# uuid.UUID(provider_reference, version=4)
|
||||||
except ValueError as e:
|
# except ValueError as e:
|
||||||
current_app.logger.exception(f'{client_name} callback with invalid reference {provider_reference}')
|
# current_app.logger.exception(f'{client_name} callback with invalid reference {provider_reference}')
|
||||||
raise e
|
# raise e
|
||||||
|
|
||||||
response_parser = sms_response_mapper[client_name]
|
# response_parser = sms_response_mapper[client_name]
|
||||||
|
|
||||||
# validate status
|
# # validate status
|
||||||
try:
|
# try:
|
||||||
notification_status, detailed_status = response_parser(status, detailed_status_code)
|
# notification_status, detailed_status = response_parser(status, detailed_status_code)
|
||||||
current_app.logger.info(
|
# current_app.logger.info(
|
||||||
f'{client_name} callback returned status of {notification_status}'
|
# f'{client_name} callback returned status of {notification_status}'
|
||||||
f'({status}): {detailed_status}({detailed_status_code}) for reference: {provider_reference}'
|
# f'({status}): {detailed_status}({detailed_status_code}) for reference: {provider_reference}'
|
||||||
)
|
# )
|
||||||
except KeyError:
|
# except KeyError:
|
||||||
_process_for_status(
|
# _process_for_status(
|
||||||
notification_status='technical-failure',
|
# notification_status='technical-failure',
|
||||||
client_name=client_name,
|
# client_name=client_name,
|
||||||
provider_reference=provider_reference
|
# provider_reference=provider_reference
|
||||||
)
|
# )
|
||||||
raise ClientException(f'{client_name} callback failed: status {status} not found.')
|
# raise ClientException(f'{client_name} callback failed: status {status} not found.')
|
||||||
|
|
||||||
_process_for_status(
|
# _process_for_status(
|
||||||
notification_status=notification_status,
|
# notification_status=notification_status,
|
||||||
client_name=client_name,
|
# client_name=client_name,
|
||||||
provider_reference=provider_reference,
|
# provider_reference=provider_reference,
|
||||||
detailed_status_code=detailed_status_code
|
# detailed_status_code=detailed_status_code
|
||||||
)
|
# )
|
||||||
|
|
||||||
|
|
||||||
def _process_for_status(notification_status, client_name, provider_reference, detailed_status_code=None):
|
def _process_for_status(notification_status, client_name, provider_reference, detailed_status_code=None):
|
||||||
|
|||||||
@@ -1,61 +1,25 @@
|
|||||||
from time import monotonic
|
|
||||||
|
|
||||||
from app.clients import Client, ClientException
|
from app.clients import Client, ClientException
|
||||||
|
|
||||||
|
|
||||||
class SmsClientResponseException(ClientException):
|
class SmsClientResponseException(ClientException):
|
||||||
'''
|
"""
|
||||||
Base Exception for SmsClientsResponses
|
Base Exception for SmsClientsResponses
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def __init__(self, message):
|
def __init__(self, message):
|
||||||
self.message = message
|
self.message = message
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"SMS client error ({self.message})"
|
return "Message {}".format(self.message)
|
||||||
|
|
||||||
|
|
||||||
class SmsClient(Client):
|
class SmsClient(Client):
|
||||||
'''
|
"""
|
||||||
Base Sms client for sending smss.
|
Base Sms client for sending smss.
|
||||||
'''
|
"""
|
||||||
|
|
||||||
def init_app(self, current_app, statsd_client):
|
def send_sms(self, *args, **kwargs):
|
||||||
self.current_app = current_app
|
raise NotImplementedError("TODO Need to implement.")
|
||||||
self.statsd_client = statsd_client
|
|
||||||
|
|
||||||
def record_outcome(self, success):
|
def get_name(self):
|
||||||
log_message = "Provider request for {} {}".format(
|
raise NotImplementedError("TODO Need to implement.")
|
||||||
self.name,
|
|
||||||
"succeeded" if success else "failed",
|
|
||||||
)
|
|
||||||
|
|
||||||
if success:
|
|
||||||
self.current_app.logger.info(log_message)
|
|
||||||
self.statsd_client.incr(f"clients.{self.name}.success")
|
|
||||||
else:
|
|
||||||
self.statsd_client.incr(f"clients.{self.name}.error")
|
|
||||||
self.current_app.logger.warning(log_message)
|
|
||||||
|
|
||||||
def send_sms(self, to, content, reference, international, sender):
|
|
||||||
start_time = monotonic()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = self.try_send_sms(to, content, reference, international, sender)
|
|
||||||
self.record_outcome(True)
|
|
||||||
except SmsClientResponseException as e:
|
|
||||||
self.record_outcome(False)
|
|
||||||
raise e
|
|
||||||
finally:
|
|
||||||
elapsed_time = monotonic() - start_time
|
|
||||||
self.statsd_client.timing(f"clients.{self.name}.request-time", elapsed_time)
|
|
||||||
self.current_app.logger.info(f"{self.name} request for {reference} finished in {elapsed_time}")
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
def try_send_sms(self, *args, **kwargs):
|
|
||||||
raise NotImplementedError('TODO Need to implement.')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
raise NotImplementedError('TODO Need to implement.')
|
|
||||||
86
app/clients/sms/aws_sns.py
Normal file
86
app/clients/sms/aws_sns.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import re
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import botocore
|
||||||
|
import phonenumbers
|
||||||
|
|
||||||
|
from app.clients.sms import SmsClient
|
||||||
|
|
||||||
|
|
||||||
|
class AwsSnsClient(SmsClient):
|
||||||
|
"""
|
||||||
|
AwsSns sms client
|
||||||
|
"""
|
||||||
|
|
||||||
|
def init_app(self, current_app, statsd_client, *args, **kwargs):
|
||||||
|
self._client = boto3.client("sns", region_name=current_app.config["AWS_REGION"])
|
||||||
|
self._long_codes_client = boto3.client("sns", region_name=current_app.config["AWS_PINPOINT_REGION"])
|
||||||
|
super(SmsClient, self).__init__(*args, **kwargs)
|
||||||
|
self.current_app = current_app
|
||||||
|
self.statsd_client = statsd_client
|
||||||
|
self.long_code_regex = re.compile(r"^\+1\d{10}$")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return 'sns'
|
||||||
|
|
||||||
|
def send_sms(self, to, content, reference, sender=None, international=False):
|
||||||
|
matched = False
|
||||||
|
|
||||||
|
for match in phonenumbers.PhoneNumberMatcher(to, "US"):
|
||||||
|
matched = True
|
||||||
|
to = phonenumbers.format_number(match.number, phonenumbers.PhoneNumberFormat.E164)
|
||||||
|
|
||||||
|
client = self._client
|
||||||
|
# See documentation
|
||||||
|
# https://docs.aws.amazon.com/sns/latest/dg/sms_publish-to-phone.html#sms_publish_sdk
|
||||||
|
attributes = {
|
||||||
|
"AWS.SNS.SMS.SMSType": {
|
||||||
|
"DataType": "String",
|
||||||
|
"StringValue": "Transactional",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# If sending with a long code number, we need to use another AWS region
|
||||||
|
# and specify the phone number we want to use as the origination number
|
||||||
|
send_with_dedicated_phone_number = self._send_with_dedicated_phone_number(sender)
|
||||||
|
if send_with_dedicated_phone_number:
|
||||||
|
client = self._long_codes_client
|
||||||
|
attributes["AWS.MM.SMS.OriginationNumber"] = {
|
||||||
|
"DataType": "String",
|
||||||
|
"StringValue": sender,
|
||||||
|
}
|
||||||
|
|
||||||
|
# If the number is US based, we must use a US Toll Free number to send the message
|
||||||
|
country = phonenumbers.region_code_for_number(match.number)
|
||||||
|
if country == "US":
|
||||||
|
client = self._long_codes_client
|
||||||
|
attributes["AWS.MM.SMS.OriginationNumber"] = {
|
||||||
|
"DataType": "String",
|
||||||
|
"StringValue": self.current_app.config["AWS_US_TOLL_FREE_NUMBER"],
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
start_time = monotonic()
|
||||||
|
response = client.publish(PhoneNumber=to, Message=content, MessageAttributes=attributes)
|
||||||
|
except botocore.exceptions.ClientError as e:
|
||||||
|
self.statsd_client.incr("clients.sns.error")
|
||||||
|
raise str(e)
|
||||||
|
except Exception as e:
|
||||||
|
self.statsd_client.incr("clients.sns.error")
|
||||||
|
raise str(e)
|
||||||
|
finally:
|
||||||
|
elapsed_time = monotonic() - start_time
|
||||||
|
self.current_app.logger.info("AWS SNS request finished in {}".format(elapsed_time))
|
||||||
|
self.statsd_client.timing("clients.sns.request-time", elapsed_time)
|
||||||
|
self.statsd_client.incr("clients.sns.success")
|
||||||
|
return response["MessageId"]
|
||||||
|
|
||||||
|
if not matched:
|
||||||
|
self.statsd_client.incr("clients.sns.error")
|
||||||
|
self.current_app.logger.error("No valid numbers found in {}".format(to))
|
||||||
|
raise ValueError("No valid numbers found for SMS delivery")
|
||||||
|
|
||||||
|
def _send_with_dedicated_phone_number(self, sender):
|
||||||
|
return sender and re.match(self.long_code_regex, sender)
|
||||||
@@ -1,88 +0,0 @@
|
|||||||
import json
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from requests import RequestException, request
|
|
||||||
|
|
||||||
from app.clients.sms import SmsClient, SmsClientResponseException
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Firetext will send a delivery receipt with three different status codes.
|
|
||||||
# The `firetext_response` maps these codes to the notification statistics status and notification status.
|
|
||||||
# If we get a pending (status = 2) delivery receipt followed by a declined (status = 1) delivery receipt we will set
|
|
||||||
# the notification status to temporary-failure rather than permanent failure.
|
|
||||||
# See the code in the notification_dao.update_notifications_status_by_id
|
|
||||||
firetext_responses = {
|
|
||||||
'0': 'delivered',
|
|
||||||
'1': 'permanent-failure',
|
|
||||||
'2': 'pending'
|
|
||||||
}
|
|
||||||
|
|
||||||
firetext_codes = {
|
|
||||||
# code '000' means 'No errors reported'
|
|
||||||
'000': {'status': 'temporary-failure', 'reason': 'No error reported'},
|
|
||||||
'101': {'status': 'permanent-failure', 'reason': 'Unknown Subscriber'},
|
|
||||||
'102': {'status': 'temporary-failure', 'reason': 'Absent Subscriber'},
|
|
||||||
'103': {'status': 'temporary-failure', 'reason': 'Subscriber Busy'},
|
|
||||||
'104': {'status': 'temporary-failure', 'reason': 'No Subscriber Memory'},
|
|
||||||
'201': {'status': 'permanent-failure', 'reason': 'Invalid Number'},
|
|
||||||
'301': {'status': 'permanent-failure', 'reason': 'SMS Not Supported'},
|
|
||||||
'302': {'status': 'temporary-failure', 'reason': 'SMS Not Supported'},
|
|
||||||
'401': {'status': 'permanent-failure', 'reason': 'Message Rejected'},
|
|
||||||
'900': {'status': 'temporary-failure', 'reason': 'Routing Error'},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_firetext_responses(status, detailed_status_code=None):
|
|
||||||
detailed_status = firetext_codes[detailed_status_code]['reason'] if firetext_codes.get(
|
|
||||||
detailed_status_code, None
|
|
||||||
) else None
|
|
||||||
return (firetext_responses[status], detailed_status)
|
|
||||||
|
|
||||||
|
|
||||||
def get_message_status_and_reason_from_firetext_code(detailed_status_code):
|
|
||||||
return firetext_codes[detailed_status_code]['status'], firetext_codes[detailed_status_code]['reason']
|
|
||||||
|
|
||||||
|
|
||||||
class FiretextClient(SmsClient):
|
|
||||||
'''
|
|
||||||
FireText sms client.
|
|
||||||
'''
|
|
||||||
|
|
||||||
def init_app(self, *args, **kwargs):
|
|
||||||
super().init_app(*args, **kwargs)
|
|
||||||
self.api_key = self.current_app.config.get('FIRETEXT_API_KEY')
|
|
||||||
self.international_api_key = self.current_app.config.get('FIRETEXT_INTERNATIONAL_API_KEY')
|
|
||||||
self.url = self.current_app.config.get('FIRETEXT_URL')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
return 'firetext'
|
|
||||||
|
|
||||||
def try_send_sms(self, to, content, reference, international, sender):
|
|
||||||
data = {
|
|
||||||
"apiKey": self.international_api_key if international else self.api_key,
|
|
||||||
"from": sender,
|
|
||||||
"to": to.replace('+', ''),
|
|
||||||
"message": content,
|
|
||||||
"reference": reference
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = request(
|
|
||||||
"POST",
|
|
||||||
self.url,
|
|
||||||
data=data,
|
|
||||||
timeout=60
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
try:
|
|
||||||
json.loads(response.text)
|
|
||||||
if response.json()['code'] != 0:
|
|
||||||
raise ValueError("Expected 'code' to be '0'")
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
raise SmsClientResponseException("Invalid response JSON")
|
|
||||||
except RequestException:
|
|
||||||
raise SmsClientResponseException("Request failed")
|
|
||||||
|
|
||||||
return response
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
from requests import RequestException, request
|
|
||||||
|
|
||||||
from app.clients.sms import SmsClient, SmsClientResponseException
|
|
||||||
|
|
||||||
mmg_response_map = {
|
|
||||||
'2': {'status': 'permanent-failure', 'substatus': {
|
|
||||||
"1": "Number does not exist",
|
|
||||||
"4": "Rejected by operator",
|
|
||||||
"5": "Unidentified Subscriber",
|
|
||||||
"9": "Undelivered",
|
|
||||||
"11": "Service for Subscriber suspended",
|
|
||||||
"12": "Illegal equipment",
|
|
||||||
"2049": "Subscriber IMSI blacklisted",
|
|
||||||
"2050": "Number blacklisted in do-not-disturb blacklist",
|
|
||||||
"2052": "Destination number blacklisted",
|
|
||||||
"2053": "Source address blacklisted"
|
|
||||||
}},
|
|
||||||
'3': {'status': 'delivered', 'substatus': {"2": "Delivered to operator", "5": "Delivered to handset"}},
|
|
||||||
'4': {'status': 'temporary-failure', 'substatus': {
|
|
||||||
"6": "Absent Subscriber",
|
|
||||||
"8": "Roaming not allowed",
|
|
||||||
"13": "SMS Not Supported",
|
|
||||||
"15": "Expired",
|
|
||||||
"27": "Absent Subscriber",
|
|
||||||
"29": "Invalid delivery report",
|
|
||||||
"32": "Delivery Failure",
|
|
||||||
}},
|
|
||||||
'5': {'status': 'permanent-failure', 'substatus': {
|
|
||||||
"6": "Network out of coverage",
|
|
||||||
"8": "Incorrect number prefix",
|
|
||||||
"10": "Number on do-not-disturb service",
|
|
||||||
"11": "Sender id not registered",
|
|
||||||
"13": "Sender id blacklisted",
|
|
||||||
"14": "Destination number blacklisted",
|
|
||||||
"19": "Routing unavailable",
|
|
||||||
"20": "Rejected by anti-flooding mechanism",
|
|
||||||
"21": "System error", # it says to retry those messages or contact support
|
|
||||||
"23": "Duplicate message id",
|
|
||||||
"24": "Message formatted incorrectly",
|
|
||||||
"25": "Message too long",
|
|
||||||
"51": "Missing recipient value",
|
|
||||||
"52": "Invalid destination",
|
|
||||||
}},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_mmg_responses(status, detailed_status_code=None):
|
|
||||||
return (mmg_response_map[status]["status"], mmg_response_map[status]["substatus"].get(detailed_status_code, None))
|
|
||||||
|
|
||||||
|
|
||||||
class MMGClientResponseException(SmsClientResponseException):
|
|
||||||
def __init__(self, response, exception):
|
|
||||||
status_code = response.status_code if response is not None else 504
|
|
||||||
text = response.text if response is not None else "Gateway Time-out"
|
|
||||||
|
|
||||||
self.status_code = status_code
|
|
||||||
self.text = text
|
|
||||||
self.exception = exception
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "Code {} text {} exception {}".format(self.status_code, self.text, str(self.exception))
|
|
||||||
|
|
||||||
|
|
||||||
class MMGClient(SmsClient):
|
|
||||||
'''
|
|
||||||
MMG sms client
|
|
||||||
'''
|
|
||||||
|
|
||||||
def init_app(self, *args, **kwargs):
|
|
||||||
super().init_app(*args, **kwargs)
|
|
||||||
self.api_key = self.current_app.config.get('MMG_API_KEY')
|
|
||||||
self.mmg_url = self.current_app.config.get('MMG_URL')
|
|
||||||
|
|
||||||
@property
|
|
||||||
def name(self):
|
|
||||||
return 'mmg'
|
|
||||||
|
|
||||||
def try_send_sms(self, to, content, reference, international, sender):
|
|
||||||
data = {
|
|
||||||
"reqType": "BULK",
|
|
||||||
"MSISDN": to,
|
|
||||||
"msg": content,
|
|
||||||
"sender": sender,
|
|
||||||
"cid": reference,
|
|
||||||
"multi": True
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = request(
|
|
||||||
"POST",
|
|
||||||
self.mmg_url,
|
|
||||||
data=json.dumps(data),
|
|
||||||
headers={
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': 'Basic {}'.format(self.api_key)
|
|
||||||
},
|
|
||||||
timeout=60
|
|
||||||
)
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
try:
|
|
||||||
json.loads(response.text)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
raise SmsClientResponseException("Invalid response JSON")
|
|
||||||
except RequestException:
|
|
||||||
raise SmsClientResponseException("Request failed")
|
|
||||||
|
|
||||||
return response
|
|
||||||
@@ -96,11 +96,16 @@ class Config(object):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# encyption secret/salt
|
# encyption secret/salt
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
ADMIN_CLIENT_SECRET = os.getenv('ADMIN_CLIENT_SECRET', 'dev-notify-secret-key')
|
||||||
DANGEROUS_SALT = os.getenv('DANGEROUS_SALT')
|
SECRET_KEY = os.getenv('SECRET_KEY', 'dev-notify-secret-key')
|
||||||
|
DANGEROUS_SALT = os.getenv('DANGEROUS_SALT', 'dev-notify-salt ')
|
||||||
|
|
||||||
# DB conection string
|
# DB conection string
|
||||||
SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI')
|
SQLALCHEMY_DATABASE_URI = os.getenv('SQLALCHEMY_DATABASE_URI')
|
||||||
|
|
||||||
|
# AWS SMS
|
||||||
|
AWS_PINPOINT_REGION = os.getenv("AWS_PINPOINT_REGION", "us-west-2")
|
||||||
|
AWS_US_TOLL_FREE_NUMBER = os.getenv("AWS_US_TOLL_FREE_NUMBER", "+18446120782")
|
||||||
|
|
||||||
# MMG API Key
|
# MMG API Key
|
||||||
MMG_API_KEY = os.getenv('MMG_API_KEY')
|
MMG_API_KEY = os.getenv('MMG_API_KEY')
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ from sqlalchemy.sql.expression import case
|
|||||||
from werkzeug.datastructures import MultiDict
|
from werkzeug.datastructures import MultiDict
|
||||||
|
|
||||||
from app import create_uuid, db, statsd_client
|
from app import create_uuid, db, statsd_client
|
||||||
from app.clients.sms.firetext import (
|
|
||||||
get_message_status_and_reason_from_firetext_code,
|
|
||||||
)
|
|
||||||
from app.dao.dao_utils import autocommit
|
from app.dao.dao_utils import autocommit
|
||||||
from app.letters.utils import LetterPDFNotFound, find_letter_pdf_in_s3
|
from app.letters.utils import LetterPDFNotFound, find_letter_pdf_in_s3
|
||||||
from app.models import (
|
from app.models import (
|
||||||
@@ -86,25 +83,6 @@ def dao_create_notification(notification):
|
|||||||
db.session.add(notification)
|
db.session.add(notification)
|
||||||
|
|
||||||
|
|
||||||
def _decide_permanent_temporary_failure(status, notification, detailed_status_code=None):
|
|
||||||
# Firetext will send us a pending status, followed by a success or failure status.
|
|
||||||
# When we get a failure status we need to look at the detailed_status_code to determine if the failure type
|
|
||||||
# is a permanent-failure or temporary-failure.
|
|
||||||
if notification.sent_by == 'firetext':
|
|
||||||
if status == NOTIFICATION_PERMANENT_FAILURE and detailed_status_code:
|
|
||||||
try:
|
|
||||||
status, reason = get_message_status_and_reason_from_firetext_code(detailed_status_code)
|
|
||||||
current_app.logger.info(
|
|
||||||
f'Updating notification id {notification.id} to status {status}, reason: {reason}')
|
|
||||||
return status
|
|
||||||
except KeyError:
|
|
||||||
current_app.logger.warning(f'Failure code {detailed_status_code} from Firetext not recognised')
|
|
||||||
# fallback option:
|
|
||||||
if status == NOTIFICATION_PERMANENT_FAILURE and notification.status == NOTIFICATION_PENDING:
|
|
||||||
status = NOTIFICATION_TEMPORARY_FAILURE
|
|
||||||
return status
|
|
||||||
|
|
||||||
|
|
||||||
def country_records_delivery(phone_prefix):
|
def country_records_delivery(phone_prefix):
|
||||||
dlr = INTERNATIONAL_BILLING_RATES[phone_prefix]['attributes']['dlr']
|
dlr = INTERNATIONAL_BILLING_RATES[phone_prefix]['attributes']['dlr']
|
||||||
return dlr and dlr.lower() == 'yes'
|
return dlr and dlr.lower() == 'yes'
|
||||||
|
|||||||
@@ -168,7 +168,8 @@ provider_cache = TTLCache(maxsize=8, ttl=10)
|
|||||||
|
|
||||||
|
|
||||||
@cached(cache=provider_cache)
|
@cached(cache=provider_cache)
|
||||||
def provider_to_use(notification_type, international=False):
|
def provider_to_use(notification_type, international=True):
|
||||||
|
international = True # TODO: remove or resolve the functionality of this
|
||||||
active_providers = [
|
active_providers = [
|
||||||
p for p in get_provider_details_by_notification_type(notification_type, international) if p.active
|
p for p in get_provider_details_by_notification_type(notification_type, international) if p.active
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1136,9 +1136,10 @@ class TemplateHistory(TemplateBase):
|
|||||||
|
|
||||||
MMG_PROVIDER = "mmg"
|
MMG_PROVIDER = "mmg"
|
||||||
FIRETEXT_PROVIDER = "firetext"
|
FIRETEXT_PROVIDER = "firetext"
|
||||||
|
SNS_PROVIDER = 'sns'
|
||||||
SES_PROVIDER = 'ses'
|
SES_PROVIDER = 'ses'
|
||||||
|
|
||||||
SMS_PROVIDERS = [MMG_PROVIDER, FIRETEXT_PROVIDER]
|
SMS_PROVIDERS = [MMG_PROVIDER, FIRETEXT_PROVIDER, SNS_PROVIDER]
|
||||||
EMAIL_PROVIDERS = [SES_PROVIDER]
|
EMAIL_PROVIDERS = [SES_PROVIDER]
|
||||||
PROVIDERS = SMS_PROVIDERS + EMAIL_PROVIDERS
|
PROVIDERS = SMS_PROVIDERS + EMAIL_PROVIDERS
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
from flask import Blueprint, json, jsonify, request
|
from flask import Blueprint, json, jsonify, request
|
||||||
|
|
||||||
from app.celery.process_sms_client_response_tasks import (
|
# from app.celery.process_sms_client_response_tasks import (
|
||||||
process_sms_client_response,
|
# process_sms_client_response,
|
||||||
)
|
# )
|
||||||
from app.config import QueueNames
|
from app.config import QueueNames
|
||||||
from app.errors import InvalidRequest, register_errors
|
from app.errors import InvalidRequest, register_errors
|
||||||
|
|
||||||
@@ -10,48 +10,48 @@ sms_callback_blueprint = Blueprint("sms_callback", __name__, url_prefix="/notifi
|
|||||||
register_errors(sms_callback_blueprint)
|
register_errors(sms_callback_blueprint)
|
||||||
|
|
||||||
|
|
||||||
@sms_callback_blueprint.route('/mmg', methods=['POST'])
|
# @sms_callback_blueprint.route('/mmg', methods=['POST'])
|
||||||
def process_mmg_response():
|
# def process_mmg_response():
|
||||||
client_name = 'MMG'
|
# client_name = 'MMG'
|
||||||
data = json.loads(request.data)
|
# data = json.loads(request.data)
|
||||||
errors = validate_callback_data(data=data,
|
# errors = validate_callback_data(data=data,
|
||||||
fields=['status', 'CID'],
|
# fields=['status', 'CID'],
|
||||||
client_name=client_name)
|
# client_name=client_name)
|
||||||
if errors:
|
# if errors:
|
||||||
raise InvalidRequest(errors, status_code=400)
|
# raise InvalidRequest(errors, status_code=400)
|
||||||
|
|
||||||
status = str(data.get('status'))
|
# status = str(data.get('status'))
|
||||||
detailed_status_code = str(data.get('substatus'))
|
# detailed_status_code = str(data.get('substatus'))
|
||||||
|
|
||||||
provider_reference = data.get('CID')
|
# provider_reference = data.get('CID')
|
||||||
|
|
||||||
process_sms_client_response.apply_async(
|
# process_sms_client_response.apply_async(
|
||||||
[status, provider_reference, client_name, detailed_status_code],
|
# [status, provider_reference, client_name, detailed_status_code],
|
||||||
queue=QueueNames.SMS_CALLBACKS,
|
# queue=QueueNames.SMS_CALLBACKS,
|
||||||
)
|
# )
|
||||||
|
|
||||||
return jsonify(result='success'), 200
|
# return jsonify(result='success'), 200
|
||||||
|
|
||||||
|
|
||||||
@sms_callback_blueprint.route('/firetext', methods=['POST'])
|
# @sms_callback_blueprint.route('/firetext', methods=['POST'])
|
||||||
def process_firetext_response():
|
# def process_firetext_response():
|
||||||
client_name = 'Firetext'
|
# client_name = 'Firetext'
|
||||||
errors = validate_callback_data(data=request.form,
|
# errors = validate_callback_data(data=request.form,
|
||||||
fields=['status', 'reference'],
|
# fields=['status', 'reference'],
|
||||||
client_name=client_name)
|
# client_name=client_name)
|
||||||
if errors:
|
# if errors:
|
||||||
raise InvalidRequest(errors, status_code=400)
|
# raise InvalidRequest(errors, status_code=400)
|
||||||
|
|
||||||
status = request.form.get('status')
|
# status = request.form.get('status')
|
||||||
detailed_status_code = request.form.get('code')
|
# detailed_status_code = request.form.get('code')
|
||||||
provider_reference = request.form.get('reference')
|
# provider_reference = request.form.get('reference')
|
||||||
|
|
||||||
process_sms_client_response.apply_async(
|
# process_sms_client_response.apply_async(
|
||||||
[status, provider_reference, client_name, detailed_status_code],
|
# [status, provider_reference, client_name, detailed_status_code],
|
||||||
queue=QueueNames.SMS_CALLBACKS,
|
# queue=QueueNames.SMS_CALLBACKS,
|
||||||
)
|
# )
|
||||||
|
|
||||||
return jsonify(result='success'), 200
|
# return jsonify(result='success'), 200
|
||||||
|
|
||||||
|
|
||||||
def validate_callback_data(data, fields, client_name):
|
def validate_callback_data(data, fields, client_name):
|
||||||
|
|||||||
@@ -18,14 +18,14 @@ def upgrade():
|
|||||||
op.create_table('provider_rates',
|
op.create_table('provider_rates',
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column('valid_from', sa.DateTime(), nullable=False),
|
sa.Column('valid_from', sa.DateTime(), nullable=False),
|
||||||
sa.Column('provider', sa.Enum('mmg', 'twilio', 'firetext', 'ses', name='providers'), nullable=False),
|
sa.Column('provider', sa.Enum('mmg', 'twilio', 'firetext', 'ses', 'sns', name='providers'), nullable=False),
|
||||||
sa.Column('rate', sa.Numeric(), nullable=False),
|
sa.Column('rate', sa.Numeric(), nullable=False),
|
||||||
sa.PrimaryKeyConstraint('id')
|
sa.PrimaryKeyConstraint('id')
|
||||||
)
|
)
|
||||||
op.create_table('provider_statistics',
|
op.create_table('provider_statistics',
|
||||||
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column('day', sa.Date(), nullable=False),
|
sa.Column('day', sa.Date(), nullable=False),
|
||||||
sa.Column('provider', sa.Enum('mmg', 'twilio', 'firetext', 'ses', name='providers'), nullable=False),
|
sa.Column('provider', sa.Enum('mmg', 'twilio', 'firetext', 'ses', 'sns', name='providers'), nullable=False),
|
||||||
sa.Column('service_id', postgresql.UUID(as_uuid=True), nullable=False),
|
sa.Column('service_id', postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
sa.Column('unit_count', sa.BigInteger(), nullable=False),
|
sa.Column('unit_count', sa.BigInteger(), nullable=False),
|
||||||
sa.ForeignKeyConstraint(['service_id'], ['services.id'], ),
|
sa.ForeignKeyConstraint(['service_id'], ['services.id'], ),
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ def upgrade():
|
|||||||
op.execute(
|
op.execute(
|
||||||
"INSERT INTO provider_details (id, display_name, identifier, priority, notification_type, active) values ('{}', 'AWS SES', 'ses', 10, 'email', true)".format(str(uuid.uuid4()))
|
"INSERT INTO provider_details (id, display_name, identifier, priority, notification_type, active) values ('{}', 'AWS SES', 'ses', 10, 'email', true)".format(str(uuid.uuid4()))
|
||||||
)
|
)
|
||||||
|
op.execute(
|
||||||
|
"INSERT INTO provider_details (id, display_name, identifier, priority, notification_type, active) values ('{}', 'AWS SNS', 'sns', 10, 'sms', true)".format(str(uuid.uuid4()))
|
||||||
|
)
|
||||||
op.execute(
|
op.execute(
|
||||||
"UPDATE provider_rates set provider_id = (select id from provider_details where identifier = 'mmg') where provider = 'mmg'"
|
"UPDATE provider_rates set provider_id = (select id from provider_details where identifier = 'mmg') where provider = 'mmg'"
|
||||||
)
|
)
|
||||||
@@ -52,6 +55,9 @@ def upgrade():
|
|||||||
op.execute(
|
op.execute(
|
||||||
"UPDATE provider_rates set provider_id = (select id from provider_details where identifier = 'ses') where provider = 'ses'"
|
"UPDATE provider_rates set provider_id = (select id from provider_details where identifier = 'ses') where provider = 'ses'"
|
||||||
)
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE provider_rates set provider_id = (select id from provider_details where identifier = 'sns') where provider = 'sns'"
|
||||||
|
)
|
||||||
op.execute(
|
op.execute(
|
||||||
"UPDATE provider_statistics set provider_id = (select id from provider_details where identifier = 'mmg') where provider = 'mmg'"
|
"UPDATE provider_statistics set provider_id = (select id from provider_details where identifier = 'mmg') where provider = 'mmg'"
|
||||||
)
|
)
|
||||||
@@ -61,6 +67,9 @@ def upgrade():
|
|||||||
op.execute(
|
op.execute(
|
||||||
"UPDATE provider_statistics set provider_id = (select id from provider_details where identifier = 'ses') where provider = 'ses'"
|
"UPDATE provider_statistics set provider_id = (select id from provider_details where identifier = 'ses') where provider = 'ses'"
|
||||||
)
|
)
|
||||||
|
op.execute(
|
||||||
|
"UPDATE provider_statistics set provider_id = (select id from provider_details where identifier = 'sns') where provider = 'sns'"
|
||||||
|
)
|
||||||
|
|
||||||
def downgrade():
|
def downgrade():
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ def upgrade():
|
|||||||
'email', datetime.utcnow(), invitation_content, service_id,
|
'email', datetime.utcnow(), invitation_content, service_id,
|
||||||
invitation_subject, user_id))
|
invitation_subject, user_id))
|
||||||
|
|
||||||
sms_code_content = '((verify_code)) is your Notify authentication code'
|
sms_code_content = '((verify_code)) is your US Notify authentication code'
|
||||||
op.execute(template_history_insert.format('36fb0730-6259-4da1-8a80-c8de22ad4246', 'Notify SMS verify code',
|
op.execute(template_history_insert.format('36fb0730-6259-4da1-8a80-c8de22ad4246', 'Notify SMS verify code',
|
||||||
'sms', datetime.utcnow(), sms_code_content, service_id, None, user_id))
|
'sms', datetime.utcnow(), sms_code_content, service_id, None, user_id))
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def upgrade():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
mobile_template_content = """Your mobile number was changed by ((servicemanagername)). Next time you sign in, your Notify authentication code will be sent to this phone."""
|
mobile_template_content = """Your mobile number was changed by ((servicemanagername)). Next time you sign in, your US Notify authentication code will be sent to this phone."""
|
||||||
|
|
||||||
mobile_template_name = "Phone number changed by service manager"
|
mobile_template_name = "Phone number changed by service manager"
|
||||||
|
|
||||||
|
|||||||
24
tests/app/clients/test_aws_sns.py
Normal file
24
tests/app/clients/test_aws_sns.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import pytest
|
||||||
|
from app import aws_sns_client
|
||||||
|
def test_send_sms_successful_returns_aws_sns_response(notify_api, mocker):
|
||||||
|
boto_mock = mocker.patch.object(aws_sns_client, '_client', create=True)
|
||||||
|
mocker.patch.object(aws_sns_client, 'statsd_client', create=True)
|
||||||
|
to = "6135555555"
|
||||||
|
content = reference = 'foo'
|
||||||
|
with notify_api.app_context():
|
||||||
|
aws_sns_client.send_sms(to, content, reference)
|
||||||
|
boto_mock.publish.assert_called_once_with(
|
||||||
|
PhoneNumber="+16135555555",
|
||||||
|
Message=content,
|
||||||
|
MessageAttributes={'AWS.SNS.SMS.SMSType': {'DataType': 'String', 'StringValue': 'Transactional'}}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_sms_returns_raises_error_if_there_is_no_valid_number_is_found(notify_api, mocker):
|
||||||
|
mocker.patch.object(aws_sns_client, '_client', create=True)
|
||||||
|
mocker.patch.object(aws_sns_client, 'statsd_client', create=True)
|
||||||
|
to = ""
|
||||||
|
content = reference = 'foo'
|
||||||
|
with pytest.raises(ValueError) as excinfo:
|
||||||
|
aws_sns_client.send_sms(to, content, reference)
|
||||||
|
assert 'No valid numbers found for SMS delivery' in str(excinfo.value)
|
||||||
Reference in New Issue
Block a user