Skip to content

Commit

Permalink
#2032 Twilio Status Lookup and Update (#2059)
Browse files Browse the repository at this point in the history
  • Loading branch information
mchlwellman authored Oct 22, 2024
1 parent 15817d4 commit d73d442
Show file tree
Hide file tree
Showing 2 changed files with 180 additions and 18 deletions.
129 changes: 111 additions & 18 deletions app/clients/sms/twilio.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
from urllib.parse import parse_qs

from twilio.rest import Client
from twilio.rest.api.v2010.account.message import MessageInstance
from twilio.base.exceptions import TwilioRestException

from app.clients.sms import SmsClient, SmsStatusRecord

from app.exceptions import InvalidProviderException


TWILIO_RESPONSE_MAP = {
'accepted': 'created',
'queued': 'sending',
Expand Down Expand Up @@ -125,6 +128,23 @@ def name(self):
def get_name(self):
return self.name

def get_twilio_message(self, message_sid: str) -> MessageInstance | None:
"""
Fetches a Twilio message by its message sid.
Args:
message_sid (str): the Twilio message id
Returns:
MessageInstance: the Twilio message instance if found, otherwise None
"""
message = None
try:
message = self._client.messages(message_sid).fetch()
except TwilioRestException:
self.logger.warning('Twilio message not found: %s', message_sid)
return message

def send_sms(
self,
to,
Expand Down Expand Up @@ -232,22 +252,104 @@ def translate_delivery_status(
self.logger.info('Translating Twilio delivery status')
self.logger.debug(twilio_delivery_status_message)

decoded_msg, parsed_dict = self._parse_twilio_message(twilio_delivery_status_message)

message_sid = parsed_dict['MessageSid'][0]
twilio_delivery_status = parsed_dict['MessageStatus'][0]
error_code = parsed_dict.get('ErrorCode', [])

status, status_reason = self._evaluate_status(message_sid, twilio_delivery_status, error_code)

status = SmsStatusRecord(
decoded_msg,
message_sid,
status,
status_reason,
)

self.logger.debug('Twilio delivery status translation: %s', status)

return status

def update_notification_status_override(self, message_sid: str) -> None:
"""
Updates the status of the notification based on the Twilio message status, bypassing any logic.
Args:
message_sid (str): the Twilio message id
Returns:
None
"""
# Importing inline to resolve a circular import error when importing at the top of the file
from app.dao.notifications_dao import dao_update_notifications_by_reference

self.logger.info('Updating notification status for message: %s', message_sid)

message = self.get_twilio_message(message_sid)
self.logger.debug('Twilio message: %s', message)

if message:
status, status_reason = self._evaluate_status(message_sid, message.status, [])
update_dict = {
'status': status,
'status_reason': status_reason,
}
updated_count, updated_history_count = dao_update_notifications_by_reference(
[
message_sid,
],
update_dict,
)
self.logger.info(
'Updated notification status for message: %s. Updated %s notifications and %s notification history',
message_sid,
updated_count,
updated_history_count,
)

def _parse_twilio_message(self, twilio_delivery_status_message: MessageInstance) -> tuple[str, dict]:
"""
Parses the base64 encoded delivery status message from Twilio and returns a dictionary.
Args:
twilio_delivery_status_message (str): the base64 encoded Twilio delivery status message
Returns:
tuple: a tuple containing the decoded message and a dictionary of the parsed message
Raises:
ValueError: if the Twilio delivery status message is empty
"""
if not twilio_delivery_status_message:
raise ValueError('Twilio delivery status message is empty')

decoded_msg = base64.b64decode(twilio_delivery_status_message).decode()

parsed_dict = parse_qs(decoded_msg)
message_sid = parsed_dict['MessageSid'][0]
twilio_delivery_status = parsed_dict['MessageStatus'][0]
error_code_data = parsed_dict.get('ErrorCode', None)

return decoded_msg, parsed_dict

def _evaluate_status(self, message_sid: str, twilio_delivery_status: str, error_codes: list) -> tuple[str, str]:
"""
Evaluates the Twilio delivery status and error codes to determine the notification status.
Args:
message_sid (str): the Twilio message id
twilio_delivery_status (str): the Twilio message status
error_codes (list): the Twilio error codes
Returns:
tuple: a tuple containing the notification status and status reason
Raises:
ValueError: if the Twilio delivery status is invalid
"""
if twilio_delivery_status not in self.twilio_notify_status_map:
value_error = f'Invalid Twilio delivery status: {twilio_delivery_status}'
raise ValueError(value_error)

if error_code_data and (twilio_delivery_status == 'failed' or twilio_delivery_status == 'undelivered'):
error_code = error_code_data[0]
if error_codes and (twilio_delivery_status == 'failed' or twilio_delivery_status == 'undelivered'):
error_code = error_codes[0]

if error_code in self.twilio_error_code_map:
notify_delivery_status: TwilioStatus = self.twilio_error_code_map[error_code]
Expand All @@ -258,21 +360,12 @@ def translate_delivery_status(
notify_delivery_status: TwilioStatus = self.twilio_notify_status_map[twilio_delivery_status]
else:
# Logic not being changed, just want to log this for now
if error_code_data:
if error_codes:
self.logger.warning(
'Error code: %s existed but status for message: %s was not failed nor undelivered',
error_code_data[0],
error_codes[0],
message_sid,
)
notify_delivery_status: TwilioStatus = self.twilio_notify_status_map[twilio_delivery_status]

status = SmsStatusRecord(
decoded_msg,
message_sid,
notify_delivery_status.status,
notify_delivery_status.status_reason,
)

self.logger.debug('Twilio delivery status translation: %s', status)

return status
return notify_delivery_status.status, notify_delivery_status.status_reason
69 changes: 69 additions & 0 deletions tests/app/clients/test_twilio.py
Original file line number Diff line number Diff line change
Expand Up @@ -687,3 +687,72 @@ def test_send_sms_raises_invalid_provider_error_with_invalid_twilio_number(
)

twilio_sms_client.send_sms(to, content, reference)


def test_get_twilio_message(
notify_api,
mocker,
):
twilio_sid = 'test_sid'
response_dict = make_twilio_message_response_dict()
response_dict['sid'] = twilio_sid

with requests_mock.Mocker() as r_mock:
r_mock.get(
f'https://api.twilio.com/2010-04-01/Accounts/{twilio_sms_client._account_sid}/Messages/{twilio_sid}.json',
json=response_dict,
status_code=200,
)

response = twilio_sms_client.get_twilio_message(twilio_sid)

assert response.sid == twilio_sid
assert response.status == 'sent'


def test_update_notification_status_override(
notify_api,
mocker,
sample_notification,
notify_db_session,
):
response_dict = make_twilio_message_response_dict()
response_dict['sid'] = 'test_sid'
response_dict['status'] = 'delivered'
twilio_sid = response_dict['sid']

notification = sample_notification(status='sending', reference=twilio_sid)

with requests_mock.Mocker() as r_mock:
r_mock.get(
f'https://api.twilio.com/2010-04-01/Accounts/{twilio_sms_client._account_sid}/Messages/{twilio_sid}.json',
json=response_dict,
status_code=200,
)

twilio_sms_client.update_notification_status_override(twilio_sid)

# Retrieve the updated notification
notify_db_session.session.refresh(notification)
assert notification.status == 'delivered'


def test_update_notification_with_unknown_sid(
notify_api,
mocker,
sample_notification,
notify_db_session,
):
twilio_sid = 'test_sid'

notification = sample_notification(status='sending', reference=twilio_sid)

# Mock the call to get_twilio_message
mocker.patch('app.clients')
mocker.patch('app.clients.sms.twilio.TwilioSMSClient.get_twilio_message', side_effect=TwilioRestException)

twilio_sms_client.update_notification_status_override(twilio_sid)

# Retrieve the updated notification
notify_db_session.session.refresh(notification)
assert notification.status == 'sending'

0 comments on commit d73d442

Please sign in to comment.