• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mozilla / fx-private-relay / 1b0c290a-0052-4fa6-9c59-ab9043d03a19

17 Mar 2025 04:52PM UTC coverage: 85.123% (-0.01%) from 85.137%
1b0c290a-0052-4fa6-9c59-ab9043d03a19

Pull #5439

circleci

vpremamozilla
changes made to baseload doc
Pull Request #5439: Update Base Load Engineer Doc

2434 of 3561 branches covered (68.35%)

Branch coverage included in aggregate %.

17048 of 19326 relevant lines covered (88.21%)

9.88 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.31
/phones/models.py
1
from __future__ import annotations
1✔
2

3
import logging
1✔
4
import secrets
1✔
5
import string
1✔
6
from collections.abc import Iterator
1✔
7
from datetime import UTC, datetime, timedelta
1✔
8
from math import floor
1✔
9

10
from django.conf import settings
1✔
11
from django.contrib.auth.models import User
1✔
12
from django.core.cache import cache
1✔
13
from django.core.exceptions import BadRequest, ValidationError
1✔
14
from django.db import models
1✔
15
from django.db.migrations.recorder import MigrationRecorder
1✔
16
from django.db.models.signals import post_save
1✔
17
from django.dispatch.dispatcher import receiver
1✔
18
from django.urls import reverse
1✔
19

20
import phonenumbers
1✔
21
from twilio.base.exceptions import TwilioRestException
1✔
22
from twilio.rest import Client
1✔
23

24
from emails.utils import incr_if_enabled
1✔
25

26
from .apps import phones_config, twilio_client
1✔
27
from .iq_utils import send_iq_sms
1✔
28

29
logger = logging.getLogger("eventsinfo")
1✔
30
events_logger = logging.getLogger("events")
1✔
31

32

33
MAX_MINUTES_TO_VERIFY_REAL_PHONE = 5
1✔
34
LAST_CONTACT_TYPE_CHOICES = [
1✔
35
    ("call", "call"),
36
    ("text", "text"),
37
]
38
DEFAULT_REGION = "US"
1✔
39

40

41
def verification_code_default():
1✔
42
    return str(secrets.randbelow(1000000)).zfill(6)
1✔
43

44

45
def verification_sent_date_default():
1✔
46
    return datetime.now(UTC)
1✔
47

48

49
def get_last_text_sender(relay_number: RelayNumber) -> InboundContact | None:
1✔
50
    """
51
    Get the last text sender.
52

53
    MPP-2581 introduces a last_text_date column for determining the last sender.
54
    Before MPP-2581, the last_inbound_date with last_inbound_type=text was used.
55
    During the transition, look at both methods.
56
    """
57
    try:
1✔
58
        latest = InboundContact.objects.filter(
1✔
59
            relay_number=relay_number, last_text_date__isnull=False
60
        ).latest("last_text_date")
61
    except InboundContact.DoesNotExist:
1✔
62
        latest = None
1✔
63

64
    try:
1✔
65
        latest_by_old_method = InboundContact.objects.filter(
1✔
66
            relay_number=relay_number, last_inbound_type="text"
67
        ).latest("last_inbound_date")
68
    except InboundContact.DoesNotExist:
1✔
69
        latest_by_old_method = None
1✔
70

71
    if (latest is None and latest_by_old_method is not None) or (
1✔
72
        latest
73
        and latest_by_old_method
74
        and latest != latest_by_old_method
75
        and latest.last_text_date
76
        and latest_by_old_method.last_inbound_date > latest.last_text_date
77
    ):
78
        # Pre-MPP-2581 server handled the latest text message
79
        return latest_by_old_method
1✔
80

81
    return latest
1✔
82

83

84
def iq_fmt(e164_number: str) -> str:
1✔
85
    return "1" + str(phonenumbers.parse(e164_number, "E164").national_number)
×
86

87

88
class VerifiedRealPhoneManager(models.Manager["RealPhone"]):
1✔
89
    """Return verified RealPhone records."""
90

91
    def get_queryset(self) -> models.query.QuerySet[RealPhone]:
1✔
92
        return super().get_queryset().filter(verified=True)
1✔
93

94
    def get_for_user(self, user: User) -> RealPhone:
1✔
95
        """Get the one verified RealPhone for the user, or raise DoesNotExist."""
96
        return self.get(user=user)
1✔
97

98
    def exists_for_number(self, number: str) -> bool:
1✔
99
        """Return True if a verified RealPhone exists for this number."""
100
        return self.filter(number=number).exists()
1✔
101

102
    def country_code_for_user(self, user: User) -> str:
1✔
103
        """Return the RealPhone country code for this user."""
104
        return self.values_list("country_code", flat=True).get(user=user)
1✔
105

106

107
class ExpiredRealPhoneManager(models.Manager["RealPhone"]):
1✔
108
    """Return RealPhone records where the sent verification is no longer valid."""
109

110
    def get_queryset(self) -> models.query.QuerySet[RealPhone]:
1✔
111
        return (
1✔
112
            super()
113
            .get_queryset()
114
            .filter(
115
                verified=False,
116
                verification_sent_date__lt=RealPhone.verification_expiration(),
117
            )
118
        )
119

120
    def delete_for_number(self, number: str) -> tuple[int, dict[str, int]]:
1✔
121
        return self.filter(number=number).delete()
1✔
122

123

124
class RecentRealPhoneManager(models.Manager["RealPhone"]):
1✔
125
    """Return RealPhone records where the sent verification is still valid."""
126

127
    def get_queryset(self) -> models.query.QuerySet[RealPhone]:
1✔
128
        return (
1✔
129
            super()
130
            .get_queryset()
131
            .filter(
132
                verified=False,
133
                verification_sent_date__gte=RealPhone.verification_expiration(),
134
            )
135
        )
136

137
    def get_for_user_number_and_verification_code(
1✔
138
        self, user: User, number: str, verification_code: str
139
    ) -> RealPhone:
140
        """Get the RealPhone with this user, number, and recently sent code, or raise"""
141
        return self.get(user=user, number=number, verification_code=verification_code)
1✔
142

143

144
class PendingRealPhoneManager(RecentRealPhoneManager):
1✔
145
    """Return unverified RealPhone records where verification is still valid."""
146

147
    def get_queryset(self) -> models.query.QuerySet[RealPhone]:
1✔
148
        return super().get_queryset().filter(verified=False)
1✔
149

150
    def exists_for_number(self, number: str) -> bool:
1✔
151
        """Return True if a verified RealPhone exists for this number."""
152
        return self.filter(number=number).exists()
1✔
153

154

155
class RealPhone(models.Model):
1✔
156
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
157
    number = models.CharField(max_length=15)
1✔
158
    verification_code = models.CharField(
1✔
159
        max_length=8, default=verification_code_default
160
    )
161
    verification_sent_date = models.DateTimeField(
1✔
162
        blank=True, null=True, default=verification_sent_date_default
163
    )
164
    verified = models.BooleanField(default=False)
1✔
165
    verified_date = models.DateTimeField(blank=True, null=True)
1✔
166
    country_code = models.CharField(max_length=2, default=DEFAULT_REGION)
1✔
167

168
    objects = models.Manager()
1✔
169
    verified_objects = VerifiedRealPhoneManager()
1✔
170
    expired_objects = ExpiredRealPhoneManager()
1✔
171
    recent_objects = RecentRealPhoneManager()
1✔
172
    pending_objects = PendingRealPhoneManager()
1✔
173

174
    class Meta:
1✔
175
        constraints = [
1✔
176
            models.UniqueConstraint(
177
                fields=["number", "verified"],
178
                condition=models.Q(verified=True),
179
                name="unique_verified_number",
180
            )
181
        ]
182

183
    @classmethod
1✔
184
    def verification_expiration(self) -> datetime:
1✔
185
        return datetime.now(UTC) - timedelta(
1✔
186
            0, 60 * settings.MAX_MINUTES_TO_VERIFY_REAL_PHONE
187
        )
188

189
    def save(self, *args, **kwargs):
1✔
190
        # delete any expired unverified RealPhone records for this number
191
        # note: it doesn't matter which user is trying to create a new
192
        # RealPhone record - any expired unverified record for the number
193
        # should be deleted
194
        RealPhone.expired_objects.delete_for_number(self.number)
1✔
195

196
        # We are not ready to support multiple real phone numbers per user,
197
        # so raise an exception if this save() would create a second
198
        # RealPhone record for the user
199
        try:
1✔
200
            verified_number = RealPhone.verified_objects.get_for_user(self.user)
1✔
201
            if not (
1✔
202
                verified_number.number == self.number
203
                and verified_number.verification_code == self.verification_code
204
            ):
205
                raise BadRequest("User already has a verified number.")
1✔
206
        except RealPhone.DoesNotExist:
1✔
207
            pass
1✔
208

209
        # call super save to save into the DB
210
        # See also: realphone_post_save receiver below
211
        return super().save(*args, **kwargs)
1✔
212

213
    def mark_verified(self):
1✔
214
        incr_if_enabled("phones_RealPhone.mark_verified")
1✔
215
        self.verified = True
1✔
216
        self.verified_date = datetime.now(UTC)
1✔
217
        self.save(force_update=True)
1✔
218
        return self
1✔
219

220

221
@receiver(post_save, sender=RealPhone, dispatch_uid="realphone_post_save")
1✔
222
def realphone_post_save(sender, instance, created, **kwargs):
1✔
223
    # don't do anything if running migrations
224
    if isinstance(instance, MigrationRecorder.Migration):
1!
225
        return
×
226

227
    if created:
1✔
228
        # only send verification_code when creating new record
229
        incr_if_enabled("phones_RealPhone.post_save_created_send_verification")
1✔
230
        text_body = (
1✔
231
            f"Your Firefox Relay verification code is {instance.verification_code}"
232
        )
233
        if settings.PHONES_NO_CLIENT_CALLS_IN_TEST:
1✔
234
            return
1✔
235
        if settings.IQ_FOR_VERIFICATION:
1!
236
            send_iq_sms(instance.number, settings.IQ_MAIN_NUMBER, text_body)
×
237
            return
×
238
        client = twilio_client()
1✔
239
        client.messages.create(
1✔
240
            body=text_body,
241
            from_=settings.TWILIO_MAIN_NUMBER,
242
            to=instance.number,
243
        )
244

245

246
def vcard_lookup_key_default():
1✔
247
    return "".join(
1✔
248
        secrets.choice(string.ascii_letters + string.digits) for i in range(6)
249
    )
250

251

252
class RelayNumber(models.Model):
1✔
253
    user = models.ForeignKey(User, on_delete=models.CASCADE)
1✔
254
    number = models.CharField(max_length=15, db_index=True, unique=True)
1✔
255
    vendor = models.CharField(max_length=15, default="twilio")
1✔
256
    location = models.CharField(max_length=255)
1✔
257
    country_code = models.CharField(max_length=2, default=DEFAULT_REGION)
1✔
258
    vcard_lookup_key = models.CharField(
1✔
259
        max_length=6, default=vcard_lookup_key_default, unique=True
260
    )
261
    enabled = models.BooleanField(default=True)
1✔
262
    remaining_seconds = models.IntegerField(
1✔
263
        default=settings.MAX_MINUTES_PER_BILLING_CYCLE * 60
264
    )
265
    remaining_texts = models.IntegerField(default=settings.MAX_TEXTS_PER_BILLING_CYCLE)
1✔
266
    calls_forwarded = models.IntegerField(default=0)
1✔
267
    calls_blocked = models.IntegerField(default=0)
1✔
268
    texts_forwarded = models.IntegerField(default=0)
1✔
269
    texts_blocked = models.IntegerField(default=0)
1✔
270
    created_at = models.DateTimeField(null=True, auto_now_add=True)
1✔
271

272
    @property
1✔
273
    def remaining_minutes(self) -> int:
1✔
274
        # return a 0 or positive int for remaining minutes
275
        return floor(max(self.remaining_seconds, 0) / 60)
1✔
276

277
    @property
1✔
278
    def calls_and_texts_forwarded(self) -> int:
1✔
279
        return self.calls_forwarded + self.texts_forwarded
1✔
280

281
    @property
1✔
282
    def calls_and_texts_blocked(self) -> int:
1✔
283
        return self.calls_blocked + self.texts_blocked
1✔
284

285
    @property
1✔
286
    def storing_phone_log(self) -> bool:
1✔
287
        return bool(self.user.profile.store_phone_log)
1✔
288

289
    def save(self, *args, **kwargs):
1✔
290
        try:
1✔
291
            realphone = RealPhone.verified_objects.get(user=self.user)
1✔
292
        except RealPhone.DoesNotExist:
1✔
293
            raise ValidationError("User does not have a verified real phone.")
1✔
294

295
        # if this number exists for this user, this is an update call
296
        existing_numbers = RelayNumber.objects.filter(user=self.user)
1✔
297
        this_number = existing_numbers.filter(number=self.number).first()
1✔
298
        if this_number and this_number.id == self.id:
1✔
299
            return super().save(*args, **kwargs)
1✔
300
        elif existing_numbers.exists():
1✔
301
            raise ValidationError("User can have only one relay number.")
1✔
302

303
        if RelayNumber.objects.filter(number=self.number).exists():
1✔
304
            raise ValidationError("This number is already claimed.")
1✔
305

306
        use_twilio = (
1✔
307
            self.vendor == "twilio" and not settings.PHONES_NO_CLIENT_CALLS_IN_TEST
308
        )
309

310
        if use_twilio:
1✔
311
            # Before saving into DB provision the number in Twilio
312
            client = twilio_client()
1✔
313

314
            # Since this will charge the Twilio account, first see if this
315
            # is running with TEST creds to avoid charges.
316
            if settings.TWILIO_TEST_ACCOUNT_SID:
1!
317
                client = phones_config().twilio_test_client
×
318

319
            twilio_incoming_number = client.incoming_phone_numbers.create(
1✔
320
                phone_number=self.number,
321
                sms_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
322
                voice_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
323
            )
324

325
        # Assume number was selected through suggested_numbers, so same country
326
        # as realphone
327
        self.country_code = realphone.country_code.upper()
1✔
328

329
        # Add numbers to the Relay messaging service, so it goes into our
330
        # A2P 10DLC campaigns
331
        if use_twilio and self.country_code in settings.TWILIO_NEEDS_10DLC_CAMPAIGN:
1✔
332
            if settings.TWILIO_MESSAGING_SERVICE_SID:
1✔
333
                register_with_messaging_service(client, twilio_incoming_number.sid)
1✔
334
            else:
335
                events_logger.warning(
1✔
336
                    "Skipping Twilio Messaging Service registration, since"
337
                    " TWILIO_MESSAGING_SERVICE_SID is empty.",
338
                    extra={"number_sid": twilio_incoming_number.sid},
339
                )
340

341
        return super().save(*args, **kwargs)
1✔
342

343

344
class CachedList:
1✔
345
    """A list that is stored in a cache."""
346

347
    def __init__(self, cache_key: str) -> None:
1✔
348
        self.cache_key = cache_key
1✔
349
        cache_value = cache.get(self.cache_key, "")
1✔
350
        if cache_value:
1✔
351
            self.data = cache_value.split(",")
1✔
352
        else:
353
            self.data = []
1✔
354

355
    def __iter__(self) -> Iterator[str]:
1✔
356
        return (item for item in self.data)
1✔
357

358
    def append(self, item: str) -> None:
1✔
359
        self.data.append(item)
1✔
360
        self.data.sort()
1✔
361
        cache.set(self.cache_key, ",".join(self.data))
1✔
362

363

364
def register_with_messaging_service(client: Client, number_sid: str) -> None:
1✔
365
    """Register a Twilio US phone number with a Messaging Service."""
366

367
    if not settings.TWILIO_MESSAGING_SERVICE_SID:
1!
368
        raise ValueError(
×
369
            "settings.TWILIO_MESSAGING_SERVICE_SID must contain a value when calling "
370
            "register_with_messaging_service"
371
        )
372

373
    closed_sids = CachedList("twilio_messaging_service_closed")
1✔
374

375
    for service_sid in settings.TWILIO_MESSAGING_SERVICE_SID:
1✔
376
        if service_sid in closed_sids:
1✔
377
            continue
1✔
378
        try:
1✔
379
            client.messaging.v1.services(service_sid).phone_numbers.create(
1✔
380
                phone_number_sid=number_sid
381
            )
382
        except TwilioRestException as err:
1✔
383
            log_extra = {
1✔
384
                "err_msg": err.msg,
385
                "status": err.status,
386
                "code": err.code,
387
                "service_sid": service_sid,
388
                "number_sid": number_sid,
389
            }
390
            if err.status == 409 and err.code == 21710:
1✔
391
                # Log "Phone Number is already in the Messaging Service"
392
                # https://www.twilio.com/docs/api/errors/21710
393
                events_logger.warning("twilio_messaging_service", extra=log_extra)
1✔
394
                return
1✔
395
            elif err.status == 412 and err.code == 21714:
1✔
396
                # Log "Number Pool size limit reached", continue to next service
397
                # https://www.twilio.com/docs/api/errors/21714
398
                closed_sids.append(service_sid)
1✔
399
                events_logger.warning("twilio_messaging_service", extra=log_extra)
1✔
400
            else:
401
                # Log and re-raise other Twilio errors
402
                events_logger.error("twilio_messaging_service", extra=log_extra)
1✔
403
                raise
1✔
404
        else:
405
            return  # Successfully registered with service
1✔
406

407
    raise Exception("All services in TWILIO_MESSAGING_SERVICE_SID are full")
1✔
408

409

410
@receiver(post_save, sender=RelayNumber)
1✔
411
def relaynumber_post_save(sender, instance, created, **kwargs):
1✔
412
    # don't do anything if running migrations
413
    if isinstance(instance, MigrationRecorder.Migration):
1!
414
        return
×
415

416
    # TODO: if IQ_FOR_NEW_NUMBERS, send welcome message via IQ
417
    if not instance.vendor == "twilio":
1!
418
        return
×
419

420
    if created:
1✔
421
        incr_if_enabled("phones_RelayNumber.post_save_created_send_welcome")
1✔
422
        if not settings.PHONES_NO_CLIENT_CALLS_IN_TEST:
1✔
423
            # only send welcome vCard when creating new record
424
            send_welcome_message(instance.user, instance)
1✔
425

426

427
def send_welcome_message(user, relay_number):
1✔
428
    real_phone = RealPhone.verified_objects.get(user=user)
1✔
429
    if not settings.SITE_ORIGIN:
1!
430
        raise ValueError(
×
431
            "settings.SITE_ORIGIN must contain a value when calling "
432
            "send_welcome_message"
433
        )
434
    media_url = settings.SITE_ORIGIN + reverse(
1✔
435
        "vCard", kwargs={"lookup_key": relay_number.vcard_lookup_key}
436
    )
437
    client = twilio_client()
1✔
438
    client.messages.create(
1✔
439
        body=(
440
            "Welcome to Relay phone masking!"
441
            " 🎉 Please add your number to your contacts."
442
            " This will help you identify your Relay messages and calls."
443
        ),
444
        from_=settings.TWILIO_MAIN_NUMBER,
445
        to=real_phone.number,
446
        media_url=[media_url],
447
    )
448

449

450
def last_inbound_date_default():
1✔
451
    return datetime.now(UTC)
1✔
452

453

454
class InboundContact(models.Model):
1✔
455
    relay_number = models.ForeignKey(RelayNumber, on_delete=models.CASCADE)
1✔
456
    inbound_number = models.CharField(max_length=15)
1✔
457
    last_inbound_date = models.DateTimeField(default=last_inbound_date_default)
1✔
458
    last_inbound_type = models.CharField(
1✔
459
        max_length=4, choices=LAST_CONTACT_TYPE_CHOICES, default="text"
460
    )
461

462
    num_calls = models.PositiveIntegerField(default=0)
1✔
463
    num_calls_blocked = models.PositiveIntegerField(default=0)
1✔
464
    last_call_date = models.DateTimeField(null=True)
1✔
465

466
    num_texts = models.PositiveIntegerField(default=0)
1✔
467
    num_texts_blocked = models.PositiveIntegerField(default=0)
1✔
468
    last_text_date = models.DateTimeField(null=True)
1✔
469

470
    blocked = models.BooleanField(default=False)
1✔
471

472
    class Meta:
1✔
473
        indexes = [models.Index(fields=["relay_number", "inbound_number"])]
1✔
474

475

476
def suggested_numbers(user):
1✔
477
    try:
1✔
478
        real_phone = RealPhone.verified_objects.get_for_user(user)
1✔
479
    except RealPhone.DoesNotExist:
1✔
480
        raise BadRequest(
1✔
481
            "available_numbers: This user hasn't verified a RealPhone yet."
482
        )
483

484
    existing_number = RelayNumber.objects.filter(user=user)
1✔
485
    if existing_number:
1✔
486
        raise BadRequest(
1✔
487
            "available_numbers: Another RelayNumber already exists for this user."
488
        )
489

490
    real_num = real_phone.number
1✔
491
    client = twilio_client()
1✔
492
    avail_nums = client.available_phone_numbers(real_phone.country_code)
1✔
493

494
    # TODO: can we make multiple pattern searches in a single Twilio API request
495
    same_prefix_options = []
1✔
496
    # look for numbers with same area code and 3-number prefix
497
    contains = f"{real_num[:8]}****" if real_num else ""
1✔
498
    twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
499
    same_prefix_options.extend(convert_twilio_numbers_to_dict(twilio_nums))
1✔
500

501
    # look for numbers with same area code, 2-number prefix and suffix
502
    contains = f"{real_num[:7]}***{real_num[10:]}" if real_num else ""
1✔
503
    twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
504
    same_prefix_options.extend(convert_twilio_numbers_to_dict(twilio_nums))
1✔
505

506
    # look for numbers with same area code and 1-number prefix
507
    contains = f"{real_num[:6]}******" if real_num else ""
1✔
508
    twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
509
    same_prefix_options.extend(convert_twilio_numbers_to_dict(twilio_nums))
1✔
510

511
    # look for same number in other area codes
512
    contains = f"+1***{real_num[5:]}" if real_num else ""
1✔
513
    twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
514
    other_areas_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
515

516
    # look for any numbers in the area code
517
    contains = f"{real_num[:5]}*******" if real_num else ""
1✔
518
    twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
519
    same_area_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
520

521
    # look for any available numbers
522
    twilio_nums = avail_nums.local.list(limit=10)
1✔
523
    random_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
524

525
    return {
1✔
526
        "real_num": real_num,
527
        "same_prefix_options": same_prefix_options,
528
        "other_areas_options": other_areas_options,
529
        "same_area_options": same_area_options,
530
        "random_options": random_options,
531
    }
532

533

534
def location_numbers(location, country_code=DEFAULT_REGION):
1✔
535
    client = twilio_client()
1✔
536
    avail_nums = client.available_phone_numbers(country_code)
1✔
537
    twilio_nums = avail_nums.local.list(in_locality=location, limit=10)
1✔
538
    return convert_twilio_numbers_to_dict(twilio_nums)
1✔
539

540

541
def area_code_numbers(area_code, country_code=DEFAULT_REGION):
1✔
542
    client = twilio_client()
1✔
543
    avail_nums = client.available_phone_numbers(country_code)
1✔
544
    twilio_nums = avail_nums.local.list(area_code=area_code, limit=10)
1✔
545
    return convert_twilio_numbers_to_dict(twilio_nums)
1✔
546

547

548
def convert_twilio_numbers_to_dict(twilio_numbers):
1✔
549
    """
550
    To serialize twilio numbers to JSON for the API,
551
    we need to convert them into dictionaries.
552
    """
553
    numbers_as_dicts = []
1✔
554
    for twilio_number in twilio_numbers:
1✔
555
        number = {}
1✔
556
        number["friendly_name"] = twilio_number.friendly_name
1✔
557
        number["iso_country"] = twilio_number.iso_country
1✔
558
        number["locality"] = twilio_number.locality
1✔
559
        number["phone_number"] = twilio_number.phone_number
1✔
560
        number["postal_code"] = twilio_number.postal_code
1✔
561
        number["region"] = twilio_number.region
1✔
562
        numbers_as_dicts.append(number)
1✔
563
    return numbers_as_dicts
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc