Change TWILIO_MESSAGING_SERVICE_SID to CSV

Change the Twilio messaging service IDs settings to a comma-separated
list. Legacy values will be treated as a list on one entry.

When registeting a US relay phone mask with a messaging service, try
each service in order, stopping after the first successful service. Take
note of full services in the cache to avoid checking them.
This commit is contained in:
John Whitlock 2023-01-17 16:38:30 -06:00 коммит произвёл luke crouch
Родитель a7c8d106ff
Коммит 709f356dd9
3 изменённых файлов: 221 добавлений и 23 удалений

Просмотреть файл

@ -1,12 +1,14 @@
from datetime import datetime, timedelta, timezone
from math import floor
from typing import Optional
import logging
import secrets
import string
from django.apps import apps
from django.contrib.auth.models import User
from django.conf import settings
from django.core.cache import cache
from django.core.exceptions import BadRequest, ValidationError
from django.db.migrations.recorder import MigrationRecorder
from django.db import models
@ -19,6 +21,9 @@ from twilio.rest import Client
from emails.utils import incr_if_enabled
logger = logging.getLogger("eventsinfo")
MAX_MINUTES_TO_VERIFY_REAL_PHONE = 5
LAST_CONTACT_TYPE_CHOICES = [
("call", "call"),
@ -282,21 +287,61 @@ class RelayNumber(models.Model):
# Add US numbers to the Relay messaging service, so it goes into our
# US A2P 10DLC campaign
if self.country_code == "US":
try:
client.messaging.v1.services(
settings.TWILIO_MESSAGING_SERVICE_SID
).phone_numbers.create(phone_number_sid=twilio_incoming_number.sid)
except TwilioRestException as err:
if err.status == 409 and err.code == 21710:
# Ignore "Phone Number is already in the Messaging Service"
# https://www.twilio.com/docs/api/errors/21710
pass
else:
raise
register_with_messaging_service(client, twilio_incoming_number.sid)
return super().save(*args, **kwargs)
def register_with_messaging_service(client: Client, number_sid: str) -> None:
"""Register a Twilio US phone number with a Messaging Service."""
if not settings.TWILIO_MESSAGING_SERVICE_SID:
raise Exception("TWILIO_MESSAGING_SERVICE_SID is unset")
cache_value = cache.get("twilio_messaging_service_closed", "")
if cache_value:
closed_sids = cache_value.split(",")
else:
closed_sids = []
for service_sid in settings.TWILIO_MESSAGING_SERVICE_SID:
if service_sid in closed_sids:
continue
try:
client.messaging.v1.services(service_sid).phone_numbers.create(
phone_number_sid=number_sid
)
except TwilioRestException as err:
log_extra = {
"err_msg": err.msg,
"status": err.status,
"code": err.code,
"service_sid": service_sid,
"number_sid": number_sid,
}
if err.status == 409 and err.code == 21710:
# Log "Phone Number is already in the Messaging Service"
# https://www.twilio.com/docs/api/errors/21710
logger.warning("twilio_messaging_service", extra=log_extra)
return
elif err.status == 412 and err.code == 21714:
# Log "Number Pool size limit reached", continue to next service
# https://www.twilio.com/docs/api/errors/21714
closed_sids.append(service_sid)
cache.set(
"twilio_messaging_service_closed", ",".join(sorted(closed_sids))
)
logger.warning("twilio_messaging_service", extra=log_extra)
else:
# Log and re-raise other Twilio errors
logger.error("twilio_messaging_service", extra=log_extra)
raise
else:
return # Successfully registered with service
raise Exception("All services in TWILIO_MESSAGING_SERVICE_SID are full")
@receiver(post_save, sender=RelayNumber)
def relaynumber_post_save(sender, instance, created, **kwargs):
# don't do anything if running migrations

Просмотреть файл

@ -2,10 +2,12 @@ from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
import random
from uuid import uuid4
from unittest.mock import Mock, patch, call
from django.conf import settings
from django.contrib.auth.models import User
from django.core.cache import cache
from django.core.exceptions import BadRequest, ValidationError
from allauth.socialaccount.models import SocialAccount, SocialToken
@ -32,6 +34,12 @@ pytestmark = pytest.mark.skipif(
)
@pytest.fixture(autouse=True)
def test_settings(settings):
settings.TWILIO_MESSAGING_SERVICE_SID = [f"MG{uuid4().hex}"]
return settings
@pytest.fixture(autouse=True)
def mock_twilio_client():
"""Mock PhonesConfig with a mock twilio client"""
@ -85,6 +93,14 @@ def phone_user(db):
return make_phone_test_user()
@pytest.fixture
def django_cache():
"""Return a cleared Django cache as a fixture."""
cache.clear()
yield cache
cache.clear()
def test_get_valid_realphone_verification_record_returns_object(phone_user):
number = "+12223334444"
real_phone = RealPhone.objects.create(
@ -287,7 +303,7 @@ def test_create_relaynumber_creates_twilio_incoming_number_and_sends_welcome(
sms_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
voice_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
)
mock_services.assert_called_once_with(settings.TWILIO_MESSAGING_SERVICE_SID)
mock_services.assert_called_once_with(settings.TWILIO_MESSAGING_SERVICE_SID[0])
mock_services.return_value.phone_numbers.create.assert_called_once_with(
phone_number_sid=twilio_incoming_number_sid
)
@ -300,9 +316,10 @@ def test_create_relaynumber_creates_twilio_incoming_number_and_sends_welcome(
def test_create_relaynumber_already_registered_with_service(
phone_user, real_phone_us, mock_twilio_client
phone_user, real_phone_us, mock_twilio_client, caplog, test_settings
):
twilio_incoming_number_sid = "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
twilio_service_sid = test_settings.TWILIO_MESSAGING_SERVICE_SID[0]
mock_messages_create = mock_twilio_client.messages.create
mock_number_create = mock_twilio_client.incoming_phone_numbers.create
mock_number_create.return_value = SimpleNamespace(sid=twilio_incoming_number_sid)
@ -311,7 +328,7 @@ def test_create_relaynumber_already_registered_with_service(
# Twilio responds that the phone number is already registered
mock_messaging_number_create.side_effect = TwilioRestException(
uri=f"/Services/{settings.TWILIO_MESSAGING_SERVICE_SID}/PhoneNumbers",
uri=f"/Services/{twilio_service_sid}/PhoneNumbers",
msg=(
"Unable to create record:"
" Phone Number or Short Code is already in the Messaging Service."
@ -330,16 +347,21 @@ def test_create_relaynumber_already_registered_with_service(
sms_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
voice_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
)
mock_services.assert_called_once_with(settings.TWILIO_MESSAGING_SERVICE_SID)
mock_services.assert_called_once_with(settings.TWILIO_MESSAGING_SERVICE_SID[0])
mock_messaging_number_create.assert_called_once_with(
phone_number_sid=twilio_incoming_number_sid
)
mock_messages_create.assert_called_once()
assert caplog.messages == ["twilio_messaging_service"]
assert caplog.records[0].code == 21710
def test_create_relaynumber_full_service(phone_user, real_phone_us, mock_twilio_client):
def test_create_relaynumber_full_service(
phone_user, real_phone_us, mock_twilio_client, test_settings, caplog
):
"""If the Twilio Messaging Service pool is full, an exception is raised."""
twilio_incoming_number_sid = "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
twilio_service_sid = test_settings.TWILIO_MESSAGING_SERVICE_SID[0]
mock_messages_create = mock_twilio_client.messages.create
mock_number_create = mock_twilio_client.incoming_phone_numbers.create
mock_number_create.return_value = SimpleNamespace(sid=twilio_incoming_number_sid)
@ -348,23 +370,154 @@ def test_create_relaynumber_full_service(phone_user, real_phone_us, mock_twilio_
# Twilio responds that the pool is full
mock_messaging_number_create.side_effect = TwilioRestException(
uri=f"/Services/{settings.TWILIO_MESSAGING_SERVICE_SID}/PhoneNumbers",
msg=("Unable to create record:" " Number Pool size limit reached"),
uri=f"/Services/{twilio_service_sid}/PhoneNumbers",
msg=("Unable to create record: Number Pool size limit reached"),
method="POST",
status=412,
code=21714,
)
relay_number = "+19998887777"
# "Pool full" exception is raised
with pytest.raises(TwilioRestException) as exc_info:
RelayNumber.objects.create(user=phone_user, number=relay_number)
assert exc_info.value.code == 21714
with pytest.raises(Exception) as exc_info:
RelayNumber.objects.create(user=phone_user, number="+19998887777")
assert (
str(exc_info.value) == "All services in TWILIO_MESSAGING_SERVICE_SID are full"
)
mock_messaging_number_create.assert_called_once_with(
phone_number_sid=twilio_incoming_number_sid
)
mock_messages_create.assert_not_called()
assert caplog.messages == ["twilio_messaging_service"]
assert caplog.records[0].code == 21714
def test_create_relaynumber_no_service(
phone_user, real_phone_us, mock_twilio_client, test_settings
):
"""If the Twilio Messaging Service pool is full, an exception is raised."""
twilio_incoming_number_sid = "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
test_settings.TWILIO_MESSAGING_SERVICE_SID = []
mock_messages_create = mock_twilio_client.messages.create
mock_number_create = mock_twilio_client.incoming_phone_numbers.create
mock_number_create.return_value = SimpleNamespace(sid=twilio_incoming_number_sid)
mock_services = mock_twilio_client.messaging.v1.services
mock_messaging_number_create = mock_services.return_value.phone_numbers.create
# "Pool full" exception is raised
with pytest.raises(Exception) as exc_info:
RelayNumber.objects.create(user=phone_user, number="+19998887777")
assert str(exc_info.value) == "TWILIO_MESSAGING_SERVICE_SID is unset"
mock_messaging_number_create.assert_not_called()
mock_messages_create.assert_not_called()
def test_create_relaynumber_fallback_service(
phone_user, real_phone_us, mock_twilio_client, settings, django_cache, caplog
):
"""The fallback messaging pool if the first is full."""
twilio_incoming_number_sid = "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
twilio_service1_sid = "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX1"
twilio_service2_sid = "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX2"
settings.TWILIO_MESSAGING_SERVICE_SID = [twilio_service1_sid, twilio_service2_sid]
django_cache.set("twilio_messaging_service_closed", "")
mock_messages_create = mock_twilio_client.messages.create
mock_number_create = mock_twilio_client.incoming_phone_numbers.create
mock_number_create.return_value = SimpleNamespace(sid=twilio_incoming_number_sid)
mock_services = mock_twilio_client.messaging.v1.services
mock_messaging_number_create = mock_services.return_value.phone_numbers.create
# Twilio responds that pool 1 is full, pool 2 is OK
mock_messaging_number_create.side_effect = [
TwilioRestException(
uri=f"/Services/{twilio_service1_sid}/PhoneNumbers",
msg=("Unable to create record: Number Pool size limit reached"),
method="POST",
status=412,
code=21714,
),
None,
]
RelayNumber.objects.create(user=phone_user, number="+19998887777")
mock_services.assert_has_calls(
[
call(twilio_service1_sid),
call().phone_numbers.create(phone_number_sid=twilio_incoming_number_sid),
call(twilio_service2_sid),
call().phone_numbers.create(phone_number_sid=twilio_incoming_number_sid),
]
)
mock_messages_create.assert_called_once()
assert django_cache.get("twilio_messaging_service_closed") == twilio_service1_sid
assert caplog.messages == ["twilio_messaging_service"]
assert caplog.records[0].code == 21714
def test_create_relaynumber_skip_to_fallback_service(
phone_user, real_phone_us, mock_twilio_client, settings, django_cache, caplog
):
"""If a pool has been marked as full, it is skipped."""
twilio_incoming_number_sid = "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
twilio_service1_sid = "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX1"
twilio_service2_sid = "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX2"
settings.TWILIO_MESSAGING_SERVICE_SID = [twilio_service1_sid, twilio_service2_sid]
django_cache.set("twilio_messaging_service_closed", twilio_service1_sid)
mock_messages_create = mock_twilio_client.messages.create
mock_number_create = mock_twilio_client.incoming_phone_numbers.create
mock_number_create.return_value = SimpleNamespace(sid=twilio_incoming_number_sid)
mock_services = mock_twilio_client.messaging.v1.services
mock_messaging_number_create = mock_services.return_value.phone_numbers.create
RelayNumber.objects.create(user=phone_user, number="+19998887777")
mock_services.assert_called_once_with(twilio_service2_sid)
mock_messaging_number_create.assert_called_once_with(
phone_number_sid=twilio_incoming_number_sid
)
mock_messages_create.assert_called_once()
assert django_cache.get("twilio_messaging_service_closed") == twilio_service1_sid
assert caplog.messages == []
def test_create_relaynumber_other_messaging_error_raised(
phone_user, real_phone_us, mock_twilio_client, test_settings, caplog, django_cache
):
"""If adding to a pool raises a different error, it is skipped."""
twilio_incoming_number_sid = "PNXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
twilio_service_sid = test_settings.TWILIO_MESSAGING_SERVICE_SID[0]
mock_messages_create = mock_twilio_client.messages.create
mock_number_create = mock_twilio_client.incoming_phone_numbers.create
mock_number_create.return_value = SimpleNamespace(sid=twilio_incoming_number_sid)
mock_services = mock_twilio_client.messaging.v1.services
mock_messaging_number_create = mock_services.return_value.phone_numbers.create
# Twilio responds that pool 1 is full, pool 2 is OK
mock_messaging_number_create.side_effect = TwilioRestException(
uri=f"/Services/{twilio_service_sid}/PhoneNumbers",
msg=(
"Unable to create record:"
" Phone Number is associated with another Messaging Service"
),
method="POST",
status=409,
code=21712,
)
with pytest.raises(TwilioRestException):
RelayNumber.objects.create(user=phone_user, number="+19998887777")
mock_services.assert_called_once_with(twilio_service_sid)
mock_messaging_number_create.assert_called_once_with(
phone_number_sid=twilio_incoming_number_sid
)
mock_messages_create.assert_not_called()
assert django_cache.get("twilio_messaging_service_closed") is None
assert caplog.messages == ["twilio_messaging_service"]
assert caplog.records[0].code == 21712
def test_create_relaynumber_canada(phone_user, mock_twilio_client):

Просмотреть файл

@ -185,7 +185,7 @@ TWILIO_ACCOUNT_SID = config("TWILIO_ACCOUNT_SID", None)
TWILIO_AUTH_TOKEN = config("TWILIO_AUTH_TOKEN", None)
TWILIO_MAIN_NUMBER = config("TWILIO_MAIN_NUMBER", None)
TWILIO_SMS_APPLICATION_SID = config("TWILIO_SMS_APPLICATION_SID", None)
TWILIO_MESSAGING_SERVICE_SID = config("TWILIO_MESSAGING_SERVICE_SID", None)
TWILIO_MESSAGING_SERVICE_SID = config("TWILIO_MESSAGING_SERVICE_SID", "", cast=Csv())
TWILIO_TEST_ACCOUNT_SID = config("TWILIO_TEST_ACCOUNT_SID", None)
TWILIO_TEST_AUTH_TOKEN = config("TWILIO_TEST_AUTH_TOKEN", None)
TWILIO_ALLOWED_COUNTRY_CODES = set(