Skip to content

Commit

Permalink
Merge pull request #5044 from mozilla/two-phones-mpp-3897
Browse files Browse the repository at this point in the history
MPP-3897: Add managers to RealPhone to handle multiple phones per user
  • Loading branch information
groovecoder authored Sep 17, 2024
2 parents a170ac9 + 3b3371f commit 9a418bd
Show file tree
Hide file tree
Showing 7 changed files with 242 additions and 106 deletions.
42 changes: 22 additions & 20 deletions api/views/phones.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,6 @@
RelayNumber,
area_code_numbers,
get_last_text_sender,
get_pending_unverified_realphone_records,
get_valid_realphone_verification_record,
get_verified_realphone_record,
get_verified_realphone_records,
location_numbers,
send_welcome_message,
suggested_numbers,
Expand Down Expand Up @@ -163,10 +159,15 @@ def create(self, request):
# number and verification code and mark it verified.
verification_code = serializer.validated_data.get("verification_code")
if verification_code:
valid_record = get_valid_realphone_verification_record(
request.user, serializer.validated_data["number"], verification_code
)
if not valid_record:
try:
valid_record = (
RealPhone.recent_objects.get_for_user_number_and_verification_code(
request.user,
serializer.validated_data["number"],
verification_code,
)
)
except RealPhone.DoesNotExist:
incr_if_enabled("phones_RealPhoneViewSet.create.invalid_verification")
raise exceptions.ValidationError(
"Could not find that verification_code for user and number."
Expand All @@ -190,16 +191,16 @@ def create(self, request):

# to prevent sending verification codes to verified numbers,
# check if the number is already a verified number.
is_verified = get_verified_realphone_record(serializer.validated_data["number"])
if is_verified:
if RealPhone.verified_objects.exists_for_number(
serializer.validated_data["number"]
):
raise ConflictError("A verified record already exists for this number.")

# to prevent abusive sending of verification messages,
# check if there is an un-expired verification code for the user
pending_unverified_records = get_pending_unverified_realphone_records(
# check if there is an un-expired verification code for number
if RealPhone.pending_objects.exists_for_number(
serializer.validated_data["number"]
)
if pending_unverified_records:
):
raise ConflictError(
"An unverified record already exists for this number.",
)
Expand Down Expand Up @@ -437,10 +438,11 @@ def search(self, request):
[apn]: https://www.twilio.com/docs/phone-numbers/api/availablephonenumberlocal-resource#read-multiple-availablephonenumberlocal-resources
""" # noqa: E501 # ignore long line for URL
incr_if_enabled("phones_RelayNumberViewSet.search")
real_phone = get_verified_realphone_records(request.user).first()
if real_phone:
country_code = real_phone.country_code
else:
try:
country_code = RealPhone.verified_objects.country_code_for_user(
request.user
)
except RealPhone.DoesNotExist:
country_code = DEFAULT_REGION
location = request.query_params.get("location")
if location is not None:
Expand Down Expand Up @@ -1166,7 +1168,7 @@ def outbound_call(request):
{"detail": "Requires outbound_phone waffle flag."}, status=403
)
try:
real_phone = RealPhone.objects.get(user=request.user, verified=True)
real_phone = RealPhone.verified_objects.get_for_user(user=request.user)
except RealPhone.DoesNotExist:
return response.Response(
{"detail": "Requires a verified real phone and phone mask."}, status=400
Expand Down Expand Up @@ -1355,7 +1357,7 @@ def _get_phone_objects(inbound_to):
# Get RelayNumber and RealPhone
try:
relay_number = RelayNumber.objects.get(number=inbound_to)
real_phone = RealPhone.objects.get(user=relay_number.user, verified=True)
real_phone = RealPhone.verified_objects.get_for_user(relay_number.user)
except ObjectDoesNotExist:
raise exceptions.ValidationError("Could not find relay number.")

Expand Down
32 changes: 18 additions & 14 deletions phones/management/commands/delete_phone_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ class _PhoneData:
"""Helper class to hold phone data for a user."""

fxa: SocialAccount
real_phone: RealPhone | None = None
real_phones: list[RealPhone] | None = None
relay_phone: RelayNumber | None = None
inbound_contact_count: int = 0

Expand All @@ -69,9 +69,8 @@ def from_fxa(cls, fxa_id: str) -> "_PhoneData":
"""Initialize from an FxA ID."""
fxa = SocialAccount.objects.get(provider="fxa", uid=fxa_id)

try:
real_phone = RealPhone.objects.get(user=fxa.user)
except RealPhone.DoesNotExist:
real_phones = RealPhone.objects.filter(user=fxa.user)
if not real_phones.exists():
return cls(fxa=fxa)

try:
Expand All @@ -80,25 +79,25 @@ def from_fxa(cls, fxa_id: str) -> "_PhoneData":
relay_number=relay_phone
).count()
except RelayNumber.DoesNotExist:
return cls(fxa=fxa, real_phone=real_phone)
return cls(fxa=fxa, real_phones=list(real_phones))

return cls(
fxa=fxa,
real_phone=real_phone,
real_phones=list(real_phones),
relay_phone=relay_phone,
inbound_contact_count=inbound_contact_count,
)

@property
def has_data(self) -> bool:
"""Return True if the user has phone data to reset."""
return self.real_phone is not None
return self.real_phones is not None and len(self.real_phones) > 0

@property
def real_number(self) -> str | None:
def real_numbers(self) -> list[str] | None:
"""Get user's real phone number, if it exists."""
if self.real_phone:
return self.real_phone.number
if self.real_phones:
return [real_phone.number for real_phone in self.real_phones]
return None

@property
Expand All @@ -114,8 +113,13 @@ def bullet_report(self) -> str:
f"* FxA ID: {self.fxa.uid}\n"
f"* User ID: {self.fxa.user_id}\n"
f"* Email: {self.fxa.user.email}\n"
f"* Real Phone: {self.real_number or '<NO REAL PHONE>'}\n"
f"* Relay Phone: {self.relay_number or '<NO RELAY PHONE>'}\n"
f"* Real Phone: "
+ (
"\n* Real Phone: ".join(number for number in self.real_numbers)
if self.real_numbers
else "<NO REAL PHONE>"
)
+ f"\n* Relay Phone: {self.relay_number or '<NO RELAY PHONE>'}\n"
f"* Inbound Contacts: {self.inbound_contact_count}\n"
)

Expand All @@ -125,5 +129,5 @@ def reset(self) -> None:
if self.inbound_contact_count:
InboundContact.objects.filter(relay_number=self.relay_phone).delete()
self.relay_phone.delete()
if self.real_phone:
self.real_phone.delete()
for real_phone in self.real_phones or []:
real_phone.delete()
149 changes: 92 additions & 57 deletions phones/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,48 +46,6 @@ def verification_sent_date_default():
return datetime.now(UTC)


def get_expired_unverified_realphone_records(number):
return RealPhone.objects.filter(
number=number,
verified=False,
verification_sent_date__lt=(
datetime.now(UTC)
- timedelta(0, 60 * settings.MAX_MINUTES_TO_VERIFY_REAL_PHONE)
),
)


def get_pending_unverified_realphone_records(number):
return RealPhone.objects.filter(
number=number,
verified=False,
verification_sent_date__gt=(
datetime.now(UTC)
- timedelta(0, 60 * settings.MAX_MINUTES_TO_VERIFY_REAL_PHONE)
),
)


def get_verified_realphone_records(user):
return RealPhone.objects.filter(user=user, verified=True)


def get_verified_realphone_record(number):
return RealPhone.objects.filter(number=number, verified=True).first()


def get_valid_realphone_verification_record(user, number, verification_code):
return RealPhone.objects.filter(
user=user,
number=number,
verification_code=verification_code,
verification_sent_date__gt=(
datetime.now(UTC)
- timedelta(0, 60 * settings.MAX_MINUTES_TO_VERIFY_REAL_PHONE)
),
).first()


def get_last_text_sender(relay_number: RelayNumber) -> InboundContact | None:
"""
Get the last text sender.
Expand Down Expand Up @@ -127,6 +85,73 @@ def iq_fmt(e164_number: str) -> str:
return "1" + str(phonenumbers.parse(e164_number, "E164").national_number)


class VerifiedRealPhoneManager(models.Manager["RealPhone"]):
"""Return verified RealPhone records."""

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

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

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

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


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

def get_queryset(self) -> models.query.QuerySet[RealPhone]:
return (
super()
.get_queryset()
.filter(
verified=False,
verification_sent_date__lt=RealPhone.verification_expiration(),
)
)

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


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

def get_queryset(self) -> models.query.QuerySet[RealPhone]:
return (
super()
.get_queryset()
.filter(
verified=False,
verification_sent_date__gte=RealPhone.verification_expiration(),
)
)

def get_for_user_number_and_verification_code(
self, user: User, number: str, verification_code: str
) -> RealPhone:
"""Get the RealPhone with this user, number, and recently sent code, or raise"""
return self.get(user=user, number=number, verification_code=verification_code)


class PendingRealPhoneManager(RecentRealPhoneManager):
"""Return unverified RealPhone records where verification is still valid."""

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

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


class RealPhone(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE)
number = models.CharField(max_length=15)
Expand All @@ -140,6 +165,12 @@ class RealPhone(models.Model):
verified_date = models.DateTimeField(blank=True, null=True)
country_code = models.CharField(max_length=2, default=DEFAULT_REGION)

objects = models.Manager()
verified_objects = VerifiedRealPhoneManager()
expired_objects = ExpiredRealPhoneManager()
recent_objects = RecentRealPhoneManager()
pending_objects = PendingRealPhoneManager()

class Meta:
constraints = [
models.UniqueConstraint(
Expand All @@ -149,29 +180,31 @@ class Meta:
)
]

@classmethod
def verification_expiration(self) -> datetime:
return datetime.now(UTC) - timedelta(
0, 60 * settings.MAX_MINUTES_TO_VERIFY_REAL_PHONE
)

def save(self, *args, **kwargs):
# delete any expired unverified RealPhone records for this number
# note: it doesn't matter which user is trying to create a new
# RealPhone record - any expired unverified record for the number
# should be deleted
expired_verification_records = get_expired_unverified_realphone_records(
self.number
)
expired_verification_records.delete()
RealPhone.expired_objects.delete_for_number(self.number)

# We are not ready to support multiple real phone numbers per user,
# so raise an exception if this save() would create a second
# RealPhone record for the user
user_verified_number_records = get_verified_realphone_records(self.user)
for verified_number in user_verified_number_records:
if (
try:
verified_number = RealPhone.verified_objects.get_for_user(self.user)
if not (
verified_number.number == self.number
and verified_number.verification_code == self.verification_code
):
# User is verifying the same number twice
return super().save(*args, **kwargs)
else:
raise BadRequest("User already has a verified number.")
except RealPhone.DoesNotExist:
pass

# call super save to save into the DB
# See also: realphone_post_save receiver below
Expand Down Expand Up @@ -254,8 +287,9 @@ def storing_phone_log(self) -> bool:
return bool(self.user.profile.store_phone_log)

def save(self, *args, **kwargs):
realphone = get_verified_realphone_records(self.user).first()
if not realphone:
try:
realphone = RealPhone.verified_objects.get(user=self.user)
except RealPhone.DoesNotExist:
raise ValidationError("User does not have a verified real phone.")

# if this number exists for this user, this is an update call
Expand Down Expand Up @@ -391,7 +425,7 @@ def relaynumber_post_save(sender, instance, created, **kwargs):


def send_welcome_message(user, relay_number):
real_phone = RealPhone.objects.get(user=user)
real_phone = RealPhone.verified_objects.get(user=user)
if not settings.SITE_ORIGIN:
raise ValueError(
"settings.SITE_ORIGIN must contain a value when calling "
Expand Down Expand Up @@ -440,8 +474,9 @@ class Meta:


def suggested_numbers(user):
real_phone = get_verified_realphone_records(user).first()
if real_phone is None:
try:
real_phone = RealPhone.verified_objects.get_for_user(user)
except RealPhone.DoesNotExist:
raise BadRequest(
"available_numbers: This user hasn't verified a RealPhone yet."
)
Expand Down
Loading

0 comments on commit 9a418bd

Please sign in to comment.