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

mozilla / fx-private-relay / 6ecd34f3-1905-4ed5-8578-96e9762a9e4e

11 Mar 2025 07:30PM CUT coverage: 85.138% (+0.02%) from 85.123%
6ecd34f3-1905-4ed5-8578-96e9762a9e4e

Pull #5440

circleci

groovecoder
fix MPP-4106: refactor(phone): update last_engagement on phone operations
Pull Request #5440: fix MPP-4106: refactor(phone): update last_engagement on phone operations

2438 of 3567 branches covered (68.35%)

Branch coverage included in aggregate %.

43 of 45 new or added lines in 2 files covered. (95.56%)

8 existing lines in 1 file now uncovered.

17091 of 19371 relevant lines covered (88.23%)

9.86 hits per line

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

95.43
/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
from privaterelay.models import Profile
1✔
26

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

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

33

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

41

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

45

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

49

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

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

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

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

82
    return latest
1✔
83

84

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

88

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

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

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

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

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

107

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

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

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

124

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

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

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

144

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

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

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

155

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

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

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

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

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

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

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

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

221

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

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

246

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

252

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

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

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

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

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

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

296
        # if this number exists for this user, this is an update call
297
        existing_numbers = RelayNumber.objects.filter(user=self.user)
1✔
298
        this_number = existing_numbers.filter(number=self.number).first()
1✔
299
        update_user_profile_last_engagement = False
1✔
300
        if this_number and this_number.id == self.id:
1✔
301
            update_user_profile_last_engagement = any(
1✔
302
                [
303
                    self.enabled != this_number.enabled,
304
                    self.calls_forwarded != this_number.calls_forwarded,
305
                    self.calls_blocked != this_number.calls_blocked,
306
                    self.texts_forwarded != this_number.texts_forwarded,
307
                    self.texts_blocked != this_number.texts_blocked,
308
                ]
309
            )
310
            if update_user_profile_last_engagement:
1✔
311
                profile: Profile = Profile.objects.get(user=self.user)
1✔
312
                profile.last_engagement = datetime.now(UTC)
1✔
313
                profile.save()
1✔
314
            return super().save(*args, **kwargs)
1✔
315
        elif existing_numbers.exists():
1✔
316
            raise ValidationError("User can have only one relay number.")
1✔
317

318
        if RelayNumber.objects.filter(number=self.number).exists():
1✔
319
            raise ValidationError("This number is already claimed.")
1✔
320

321
        use_twilio = (
1✔
322
            self.vendor == "twilio" and not settings.PHONES_NO_CLIENT_CALLS_IN_TEST
323
        )
324

325
        if use_twilio:
1✔
326
            # Before saving into DB provision the number in Twilio
327
            client = twilio_client()
1✔
328

329
            # Since this will charge the Twilio account, first see if this
330
            # is running with TEST creds to avoid charges.
331
            if settings.TWILIO_TEST_ACCOUNT_SID:
1!
UNCOV
332
                client = phones_config().twilio_test_client
×
333

334
            twilio_incoming_number = client.incoming_phone_numbers.create(
1✔
335
                phone_number=self.number,
336
                sms_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
337
                voice_application_sid=settings.TWILIO_SMS_APPLICATION_SID,
338
            )
339

340
        # Assume number was selected through suggested_numbers, so same country
341
        # as realphone
342
        self.country_code = realphone.country_code.upper()
1✔
343

344
        # Add numbers to the Relay messaging service, so it goes into our
345
        # A2P 10DLC campaigns
346
        if use_twilio and self.country_code in settings.TWILIO_NEEDS_10DLC_CAMPAIGN:
1✔
347
            if settings.TWILIO_MESSAGING_SERVICE_SID:
1✔
348
                register_with_messaging_service(client, twilio_incoming_number.sid)
1✔
349
            else:
350
                events_logger.warning(
1✔
351
                    "Skipping Twilio Messaging Service registration, since"
352
                    " TWILIO_MESSAGING_SERVICE_SID is empty.",
353
                    extra={"number_sid": twilio_incoming_number.sid},
354
                )
355

356
        return super().save(*args, **kwargs)
1✔
357

358

359
class CachedList:
1✔
360
    """A list that is stored in a cache."""
361

362
    def __init__(self, cache_key: str) -> None:
1✔
363
        self.cache_key = cache_key
1✔
364
        cache_value = cache.get(self.cache_key, "")
1✔
365
        if cache_value:
1✔
366
            self.data = cache_value.split(",")
1✔
367
        else:
368
            self.data = []
1✔
369

370
    def __iter__(self) -> Iterator[str]:
1✔
371
        return (item for item in self.data)
1✔
372

373
    def append(self, item: str) -> None:
1✔
374
        self.data.append(item)
1✔
375
        self.data.sort()
1✔
376
        cache.set(self.cache_key, ",".join(self.data))
1✔
377

378

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

382
    if not settings.TWILIO_MESSAGING_SERVICE_SID:
1!
UNCOV
383
        raise ValueError(
×
384
            "settings.TWILIO_MESSAGING_SERVICE_SID must contain a value when calling "
385
            "register_with_messaging_service"
386
        )
387

388
    closed_sids = CachedList("twilio_messaging_service_closed")
1✔
389

390
    for service_sid in settings.TWILIO_MESSAGING_SERVICE_SID:
1✔
391
        if service_sid in closed_sids:
1✔
392
            continue
1✔
393
        try:
1✔
394
            client.messaging.v1.services(service_sid).phone_numbers.create(
1✔
395
                phone_number_sid=number_sid
396
            )
397
        except TwilioRestException as err:
1✔
398
            log_extra = {
1✔
399
                "err_msg": err.msg,
400
                "status": err.status,
401
                "code": err.code,
402
                "service_sid": service_sid,
403
                "number_sid": number_sid,
404
            }
405
            if err.status == 409 and err.code == 21710:
1✔
406
                # Log "Phone Number is already in the Messaging Service"
407
                # https://www.twilio.com/docs/api/errors/21710
408
                events_logger.warning("twilio_messaging_service", extra=log_extra)
1✔
409
                return
1✔
410
            elif err.status == 412 and err.code == 21714:
1✔
411
                # Log "Number Pool size limit reached", continue to next service
412
                # https://www.twilio.com/docs/api/errors/21714
413
                closed_sids.append(service_sid)
1✔
414
                events_logger.warning("twilio_messaging_service", extra=log_extra)
1✔
415
            else:
416
                # Log and re-raise other Twilio errors
417
                events_logger.error("twilio_messaging_service", extra=log_extra)
1✔
418
                raise
1✔
419
        else:
420
            return  # Successfully registered with service
1✔
421

422
    raise Exception("All services in TWILIO_MESSAGING_SERVICE_SID are full")
1✔
423

424

425
@receiver(post_save, sender=RelayNumber)
1✔
426
def relaynumber_post_save(sender, instance, created, **kwargs):
1✔
427
    # don't do anything if running migrations
428
    if isinstance(instance, MigrationRecorder.Migration):
1!
UNCOV
429
        return
×
430

431
    # TODO: if IQ_FOR_NEW_NUMBERS, send welcome message via IQ
432
    if not instance.vendor == "twilio":
1!
UNCOV
433
        return
×
434

435
    if created:
1✔
436
        incr_if_enabled("phones_RelayNumber.post_save_created_send_welcome")
1✔
437
        if not settings.PHONES_NO_CLIENT_CALLS_IN_TEST:
1✔
438
            # only send welcome vCard when creating new record
439
            send_welcome_message(instance.user, instance)
1✔
440

441

442
def send_welcome_message(user, relay_number):
1✔
443
    real_phone = RealPhone.verified_objects.get(user=user)
1✔
444
    if not settings.SITE_ORIGIN:
1!
UNCOV
445
        raise ValueError(
×
446
            "settings.SITE_ORIGIN must contain a value when calling "
447
            "send_welcome_message"
448
        )
449
    media_url = settings.SITE_ORIGIN + reverse(
1✔
450
        "vCard", kwargs={"lookup_key": relay_number.vcard_lookup_key}
451
    )
452
    client = twilio_client()
1✔
453
    client.messages.create(
1✔
454
        body=(
455
            "Welcome to Relay phone masking!"
456
            " 🎉 Please add your number to your contacts."
457
            " This will help you identify your Relay messages and calls."
458
        ),
459
        from_=settings.TWILIO_MAIN_NUMBER,
460
        to=real_phone.number,
461
        media_url=[media_url],
462
    )
463

464

465
def last_inbound_date_default():
1✔
466
    return datetime.now(UTC)
1✔
467

468

469
class InboundContact(models.Model):
1✔
470
    relay_number = models.ForeignKey(RelayNumber, on_delete=models.CASCADE)
1✔
471
    inbound_number = models.CharField(max_length=15)
1✔
472
    last_inbound_date = models.DateTimeField(default=last_inbound_date_default)
1✔
473
    last_inbound_type = models.CharField(
1✔
474
        max_length=4, choices=LAST_CONTACT_TYPE_CHOICES, default="text"
475
    )
476

477
    num_calls = models.PositiveIntegerField(default=0)
1✔
478
    num_calls_blocked = models.PositiveIntegerField(default=0)
1✔
479
    last_call_date = models.DateTimeField(null=True)
1✔
480

481
    num_texts = models.PositiveIntegerField(default=0)
1✔
482
    num_texts_blocked = models.PositiveIntegerField(default=0)
1✔
483
    last_text_date = models.DateTimeField(null=True)
1✔
484

485
    blocked = models.BooleanField(default=False)
1✔
486

487
    class Meta:
1✔
488
        indexes = [models.Index(fields=["relay_number", "inbound_number"])]
1✔
489

490

491
def suggested_numbers(user):
1✔
492
    try:
1✔
493
        real_phone = RealPhone.verified_objects.get_for_user(user)
1✔
494
    except RealPhone.DoesNotExist:
1✔
495
        raise BadRequest(
1✔
496
            "available_numbers: This user hasn't verified a RealPhone yet."
497
        )
498

499
    existing_number = RelayNumber.objects.filter(user=user)
1✔
500
    if existing_number:
1✔
501
        raise BadRequest(
1✔
502
            "available_numbers: Another RelayNumber already exists for this user."
503
        )
504

505
    real_num = real_phone.number
1✔
506
    client = twilio_client()
1✔
507
    avail_nums = client.available_phone_numbers(real_phone.country_code)
1✔
508

509
    # TODO: can we make multiple pattern searches in a single Twilio API request
510
    same_prefix_options = []
1✔
511
    # look for numbers with same area code and 3-number prefix
512
    contains = f"{real_num[:8]}****" if real_num else ""
1✔
513
    twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
514
    same_prefix_options.extend(convert_twilio_numbers_to_dict(twilio_nums))
1✔
515

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

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

526
    # look for same number in other area codes
527
    contains = f"+1***{real_num[5:]}" if real_num else ""
1✔
528
    twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
529
    other_areas_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
530

531
    # look for any numbers in the area code
532
    contains = f"{real_num[:5]}*******" if real_num else ""
1✔
533
    twilio_nums = avail_nums.local.list(contains=contains, limit=10)
1✔
534
    same_area_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
535

536
    # look for any available numbers
537
    twilio_nums = avail_nums.local.list(limit=10)
1✔
538
    random_options = convert_twilio_numbers_to_dict(twilio_nums)
1✔
539

540
    return {
1✔
541
        "real_num": real_num,
542
        "same_prefix_options": same_prefix_options,
543
        "other_areas_options": other_areas_options,
544
        "same_area_options": same_area_options,
545
        "random_options": random_options,
546
    }
547

548

549
def location_numbers(location, country_code=DEFAULT_REGION):
1✔
550
    client = twilio_client()
1✔
551
    avail_nums = client.available_phone_numbers(country_code)
1✔
552
    twilio_nums = avail_nums.local.list(in_locality=location, limit=10)
1✔
553
    return convert_twilio_numbers_to_dict(twilio_nums)
1✔
554

555

556
def area_code_numbers(area_code, country_code=DEFAULT_REGION):
1✔
557
    client = twilio_client()
1✔
558
    avail_nums = client.available_phone_numbers(country_code)
1✔
559
    twilio_nums = avail_nums.local.list(area_code=area_code, limit=10)
1✔
560
    return convert_twilio_numbers_to_dict(twilio_nums)
1✔
561

562

563
def convert_twilio_numbers_to_dict(twilio_numbers):
1✔
564
    """
565
    To serialize twilio numbers to JSON for the API,
566
    we need to convert them into dictionaries.
567
    """
568
    numbers_as_dicts = []
1✔
569
    for twilio_number in twilio_numbers:
1✔
570
        number = {}
1✔
571
        number["friendly_name"] = twilio_number.friendly_name
1✔
572
        number["iso_country"] = twilio_number.iso_country
1✔
573
        number["locality"] = twilio_number.locality
1✔
574
        number["phone_number"] = twilio_number.phone_number
1✔
575
        number["postal_code"] = twilio_number.postal_code
1✔
576
        number["region"] = twilio_number.region
1✔
577
        numbers_as_dicts.append(number)
1✔
578
    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