Skip to content

Commit

Permalink
add phone number to GCE and trigger Textit campaign (#2467)
Browse files Browse the repository at this point in the history
Adds phone number field to existing GCE responses model
Adds method to add the user's number to Texit campaign to confirm their number
Changes some internal textit (aka rapidpro) methods to allow for missing name, since we aren't collecting for GCE
Adds a few simple tests for:
the optional name on rapidpro methods
the triggering of campaign from GCE model
validating phone_number values
Currently the textit campaign trigger happens whenever a phone number is sent from the client. Textit will prevent duplicate copies of a contact being added to the group (since there is a single list of all contacts and they can be members of a group), but repeated submissions of a number will trigger repeated confirmation messages. But I didn't think that was a problem.

I've set up the campaign to send a single message right after the number is submitted. TBD on exact content for that, but it's all managed in textit.
  • Loading branch information
austensen authored Jan 9, 2025
1 parent 547ac93 commit 4dbf7eb
Show file tree
Hide file tree
Showing 14 changed files with 125 additions and 9 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 3.2.13 on 2025-01-03 21:25

from django.db import migrations, models
import project.util.phone_number


class Migration(migrations.Migration):

dependencies = [
('gce', '0001_initial'),
]

operations = [
migrations.AddField(
model_name='goodcauseevictionscreenerresponse',
name='phone_number',
field=models.CharField(blank=True, help_text='A U.S. phone number without parentheses or hyphens, e.g. "5551234567".', max_length=10, validators=[project.util.phone_number.validate_phone_number]),
),
]
27 changes: 27 additions & 0 deletions gce/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import logging
from django.db import models

from project.common_data import Choices
from project.util.address_form_fields import BOROUGH_FIELD_KWARGS
from project.util import phone_number as pn

COVERAGE = Choices(
[
Expand All @@ -14,10 +16,17 @@

class GoodCauseEvictionScreenerResponse(models.Model):

RAPIDPRO_CAMPAIGN = "GCE"

created_at = models.DateTimeField(auto_now_add=True)

updated_at = models.DateTimeField(auto_now=True)

phone_number = models.CharField(
blank=True,
**pn.get_model_field_kwargs(),
)

bbl: str = models.CharField(
max_length=10, # One for the borough, 5 for the block, 4 for the lot.
help_text=(
Expand Down Expand Up @@ -103,3 +112,21 @@ class GoodCauseEvictionScreenerResponse(models.Model):
blank=True,
null=True,
)

def trigger_followup_campaign_async(self) -> None:
if not self.phone_number:
return

from rapidpro import followup_campaigns as fc

fc.ensure_followup_campaign_exists(self.RAPIDPRO_CAMPAIGN)

logging.info(
f"Triggering rapidpro campaign '{self.RAPIDPRO_CAMPAIGN}' on user " f"{self.pk}."
)
fc.trigger_followup_campaign_async(
None, # We aren't collecting names
self.phone_number,
self.RAPIDPRO_CAMPAIGN,
locale="en", # We don't support other languages for GCE yet
)
17 changes: 17 additions & 0 deletions gce/tests/test_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import pytest
from unittest.mock import MagicMock
from gce.models import GoodCauseEvictionScreenerResponse


class TestTriggerFollowupCampaign:
@pytest.fixture(autouse=True)
def setup_fixture(self, monkeypatch):
from rapidpro import followup_campaigns

self.trigger = MagicMock()
monkeypatch.setattr(followup_campaigns, "trigger_followup_campaign_async", self.trigger)

def test_it_triggers_followup_campaign_if_user_has_phone_number(self, db):
gcer = GoodCauseEvictionScreenerResponse(bbl="1234567890", phone_number="2125551234")
gcer.trigger_followup_campaign_async()
self.trigger.assert_called_once_with(None, "2125551234", "GCE", locale="en")
5 changes: 5 additions & 0 deletions gce/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ def base_headers(settings):
}

INVALID_DATA = {
"phone_number": "123",
"bbl": "X",
"address_confirmed": "X",
"form_answers": {
Expand All @@ -60,6 +61,10 @@ def base_headers(settings):
}

INVALID_ERRORS = [
{
"loc": ["phone_number"],
"msg": "U.S. phone numbers must be 10 digits.",
},
{
"loc": ["bbl"],
"msg": "BBL must be 10-digit zero padded string",
Expand Down
21 changes: 18 additions & 3 deletions gce/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,17 @@
import logging
import json
import re
from typing import Any, Dict, Literal, Optional, Set

import pydantic
from pydantic.error_wrappers import ValidationError
from django.conf import settings
from django.http import JsonResponse
from typing import Any, Dict, Literal, Optional, Set
import pydantic.error_wrappers as pde
import django.core.exceptions as dje


from gce.models import GoodCauseEvictionScreenerResponse
from project.util import phone_number as pn

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -41,6 +46,7 @@ class ResultCriteria(pydantic.BaseModel):

class GcePostData(pydantic.BaseModel):
id: Optional[int]
phone_number: Optional[str]
bbl: Optional[str]
house_number: Optional[str]
street_name: Optional[str]
Expand All @@ -60,14 +66,23 @@ def bbl_must_match_pattern(cls, v):
raise ValueError("BBL must be 10-digit zero padded string")
return v

@pydantic.validator("phone_number") # type: ignore
def phone_number_must_be_valid(cls, v):
try:
pn.validate_phone_number(v)
except dje.ValidationError as e:
# Avoid mixing pydantic and django versions of ValidationError
raise ValueError(getattr(e, "message"))
return v

def dict_exclude_none(self):
return {k: v for k, v in self.dict().items() if v is not None}


def validate_data(request):
try:
data = GcePostData(**json.loads(request.body.decode("utf-8")))
except ValidationError as e:
except pde.ValidationError as e:
if getattr(e, "errors"):
raise DataValidationError(e.errors())
else:
Expand Down
3 changes: 3 additions & 0 deletions gce/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ def upload(request):
update_gce_record(gcer, data)
gcer.save()

if data.phone_number:
gcer.trigger_followup_campaign_async()

return JsonResponse(
{"id": gcer.pk},
content_type="application/json",
Expand Down
5 changes: 5 additions & 0 deletions project/justfix_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,11 @@ class JustfixEnvironment(typed_environ.BaseEnvironment):
# follow-up campaign will be disabled.
RAPIDPRO_FOLLOWUP_CAMPAIGN_EHP: str = ""

# The RapidPro group name and date field key, separated by a comma, that
# trigger the follow-up campaign for Good Cause (Eviction) NYC. If empty, this
# follow-up campaign will be disabled.
RAPIDPRO_FOLLOWUP_CAMPAIGN_GCE: str = ""

# The DocuSign account ID to use. Leaving this empty disables DocuSign
# integration.
DOCUSIGN_ACCOUNT_ID: str = ""
Expand Down
1 change: 1 addition & 0 deletions project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,7 @@
RAPIDPRO_FOLLOWUP_CAMPAIGN_LOC = env.RAPIDPRO_FOLLOWUP_CAMPAIGN_LOC
RAPIDPRO_FOLLOWUP_CAMPAIGN_HP = env.RAPIDPRO_FOLLOWUP_CAMPAIGN_HP
RAPIDPRO_FOLLOWUP_CAMPAIGN_EHP = env.RAPIDPRO_FOLLOWUP_CAMPAIGN_EHP
RAPIDPRO_FOLLOWUP_CAMPAIGN_GCE = env.RAPIDPRO_FOLLOWUP_CAMPAIGN_GCE

LOB_SECRET_API_KEY = env.LOB_SECRET_API_KEY

Expand Down
1 change: 1 addition & 0 deletions project/settings_pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
RAPIDPRO_FOLLOWUP_CAMPAIGN_LOC = ""
RAPIDPRO_FOLLOWUP_CAMPAIGN_HP = ""
RAPIDPRO_FOLLOWUP_CAMPAIGN_EHP = ""
RAPIDPRO_FOLLOWUP_CAMPAIGN_GCE = ""
LOB_SECRET_API_KEY = ""
LOB_PUBLISHABLE_API_KEY = ""
DOCUSIGN_ACCOUNT_ID = ""
Expand Down
8 changes: 6 additions & 2 deletions rapidpro/followup_campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,11 @@ def add_to_group_and_update_date_field(self, client: TembaClient, contact: Conta
)

def add_contact(
self, client: TembaClient, full_preferred_name: str, phone_number: str, locale: str
self,
client: TembaClient,
full_preferred_name: Optional[str],
phone_number: str,
locale: str,
):
"""
Add the given contact to the follow-up campaign, creating a new RapidPro contact
Expand Down Expand Up @@ -138,7 +142,7 @@ def from_string(cls, value: str) -> Optional["FollowupCampaign"]:


def trigger_followup_campaign_async(
full_preferred_name: str, phone_number: str, campaign_name: str, locale: str
full_preferred_name: Optional[str], phone_number: str, campaign_name: str, locale: str
):
"""
Add the given contact to the given follow-up campaign from Django settings, e.g.:
Expand Down
2 changes: 1 addition & 1 deletion rapidpro/rapidpro_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def get_field(client: TembaClient, key: str) -> Field:


def get_or_create_contact(
client: TembaClient, name: str, phone_number: str, locale: str
client: TembaClient, name: Optional[str], phone_number: str, locale: str
) -> Contact:
"""
Retrieve the contact with the given phone number, creating them (and providing the
Expand Down
8 changes: 7 additions & 1 deletion rapidpro/tasks.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import Optional

from celery import shared_task
from temba_client.exceptions import TembaHttpError

Expand All @@ -11,7 +13,11 @@
# versions of the codebase which never supplied this argument.
@shared_task(bind=True, retry_backoff=True, default_retry_delay=30 * 60)
def trigger_followup_campaign(
self, full_preferred_name: str, phone_number: str, campaign_name: str, locale: str = "en"
self,
full_preferred_name: Optional[str],
phone_number: str,
campaign_name: str,
locale: str = "en",
):
client = get_client_from_settings()
campaign = DjangoSettingsFollowupCampaigns.get_campaign(campaign_name)
Expand Down
13 changes: 13 additions & 0 deletions rapidpro/tests/test_followup_campaigns.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,19 @@ def test_add_contact_works(self):
fields={"fake_field": "blah", "date_of_boop": "2018-01-02T00:00:00.000000Z"},
)

def test_add_contact_works_without_name(self):
contact = Contact.create(groups=["FAKE ARG GROUP"], fields={"fake_field": "blah"})
client, _ = make_client_mocks("get_contacts", contact)
mock_query(client, "get_groups", "FAKE BOOP GROUP")
campaign = FollowupCampaign("Boop Group", "date_of_boop")
with freeze_time("2018-01-02"):
campaign.add_contact(client, None, "5551234567", "en")
client.update_contact.assert_called_once_with(
contact,
groups=["FAKE ARG GROUP", "FAKE BOOP GROUP"],
fields={"fake_field": "blah", "date_of_boop": "2018-01-02T00:00:00.000000Z"},
)

def test_wont_add_contact_to_group_if_they_have_blocked_or_stopped_us(self):
contact = Contact.create(
groups=["FAKE ARG GROUP"], fields={"fake_field": "blah"}, blocked=True
Expand Down
4 changes: 2 additions & 2 deletions texting/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,11 @@ def join_words(*words: Optional[str]) -> str:
class PhoneNumberLookupManager(models.Manager):
def get_or_lookup(self, phone_number: str) -> Optional["PhoneNumberLookup"]:
"""
Attept to retrieve the PhoneNumberLookup with the given phone number.
Attempt to retrieve the PhoneNumberLookup with the given phone number.
If one doesn't exist, attempt to contact Twilio to validate the number
and obtain carrier information about it.
Return None if Twilio integration is disabled or a network error occured.
Return None if Twilio integration is disabled or a network error occurred.
"""

from .twilio import is_phone_number_valid
Expand Down

0 comments on commit 4dbf7eb

Please sign in to comment.