class RentalHistoryRequest(models.Model): created_at = models.DateTimeField(auto_now_add=True) first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=150) apartment_number = models.CharField(max_length=15) phone_number = models.CharField(**pn.get_model_field_kwargs()) address = models.CharField(**ADDRESS_FIELD_KWARGS) address_verified = models.BooleanField() borough = models.CharField(**BOROUGH_FIELD_KWARGS) zipcode = models.CharField(max_length=5, blank=True) user = models.ForeignKey( JustfixUser, on_delete=models.SET_NULL, blank=True, null=True, help_text=( "User who was logged in when the rent history request was made. " "This may or may not be different from the actual name/address of the " "request, e.g. if the user is making a request on someone else's " "behalf." ), ) def set_user(self, user: JustfixUser): self.user = user if user.is_authenticated else None
class JustfixUser(AbstractUser): class Meta: verbose_name = "user" verbose_name_plural = "users" permissions = [ ("impersonate_users", "Can impersonate other users"), ( "download_sandefur_data", "Can download data needed for Rebecca Sandefur's research", ), ] preferred_first_name = models.CharField( "Preferred first name", max_length=150, blank=True, help_text="The first name Justfix will call the user by. Optional. " " May be different from their legal first name.", ) phone_number = models.CharField("Phone number", unique=True, **pn.get_model_field_kwargs()) is_email_verified = models.BooleanField( default=False, help_text=("Whether the user has verified that they 'own' their email " "address by clicking on a link we emailed them."), ) locale = models.CharField( **LOCALE_KWARGS, help_text="The user's preferred locale/language.", ) objects = JustfixUserManager() USERNAME_FIELD = "phone_number" REQUIRED_FIELDS = ["username", "email"] @property def full_legal_name(self) -> str: if self.first_name and self.last_name: return " ".join([self.first_name, self.last_name]) return "" @property def full_preferred_name(self) -> str: if self.best_first_name and self.last_name: return " ".join([self.best_first_name, self.last_name]) return "" @property def best_first_name(self) -> str: return self.preferred_first_name if self.preferred_first_name else self.first_name def as_email_recipient(self) -> Optional[str]: """ Attempts to construct the most informative string that can be pasted into an email's to/cc/bcc/reply-to field. If a user has only an email address, it will return that: >>> u = JustfixUser(email="*****@*****.**") >>> u.as_email_recipient() '*****@*****.**' But if the user has a full name, it will also return that: >>> u.first_name = "Boop" >>> u.last_name = "Jones" >>> u.as_email_recipient() 'Boop Jones <*****@*****.**>' And if the user has no email, it will just return None: >>> JustfixUser().as_email_recipient() is None True """ value: str = self.email if not value: return None if self.full_legal_name: value = f"{self.full_legal_name} <{value}>" return value def formatted_phone_number(self) -> str: return pn.humanize(self.phone_number) @property def can_we_sms(self) -> bool: return hasattr(self, "onboarding_info") and self.onboarding_info.can_we_sms def _log_sms(self): if self.can_we_sms: logging.info(f"Sending a SMS to user {self.username}.") else: logging.info( f"Not sending a SMS to user {self.username} because they opted out." ) def send_sms(self, body: str, fail_silently=True) -> twilio.SendSmsResult: self._log_sms() if self.can_we_sms: return twilio.send_sms(self.phone_number, body, fail_silently=fail_silently) return twilio.SendSmsResult(err_code=twilio.TWILIO_USER_OPTED_OUT_ERR) def send_sms_async(self, body: str) -> None: self._log_sms() if self.can_we_sms: twilio.send_sms_async(self.phone_number, body) def chain_sms_async(self, bodies: List[str]) -> None: self._log_sms() if self.can_we_sms: twilio.chain_sms_async(self.phone_number, bodies) def trigger_followup_campaign_async(self, campaign_name: str) -> None: from rapidpro import followup_campaigns as fc fc.ensure_followup_campaign_exists(campaign_name) if self.can_we_sms: logging.info( f"Triggering rapidpro campaign '{campaign_name}' on user " f"{self.username}.") fc.trigger_followup_campaign_async( self. full_preferred_name, # Use the user's preferred name when we text them. self.phone_number, campaign_name, locale=self.locale, ) else: logging.info( f"Not triggering rapidpro campaign '{campaign_name}' on user " f"{self.username} because they opted out.") @property def admin_url(self): return absolute_reverse("admin:users_justfixuser_change", args=[self.pk]) @property def amplitude_url(self) -> Optional[str]: from amplitude.util import get_url_for_user_page return get_url_for_user_page(self) def __str__(self): if self.username: return self.username return "<unnamed user>"
class TenantResource(models.Model): name = models.CharField(max_length=150, help_text="The name of the tenant resource.") website = models.URLField( blank=True, help_text="The primary website of the tenant resource.") phone_number = models.CharField( 'Phone number', blank=True, **pn.get_model_field_kwargs(), ) description = models.TextField( blank=True, help_text= "The description of the tenant resource, including the services it provides." ) org_type = models.CharField( max_length=40, blank=True, choices=ORG_TYPE_CHOICES.choices, help_text="The organization type of the tenant resource.") address = models.TextField( help_text= "The street address of the resource's office, including borough.") zipcodes = models.ManyToManyField(Zipcode, blank=True) boroughs = models.ManyToManyField(Borough, blank=True) neighborhoods = models.ManyToManyField(Neighborhood, blank=True) community_districts = models.ManyToManyField(CommunityDistrict, blank=True) geocoded_address = models.TextField( blank=True, help_text= ("This is the definitive street address returned by the geocoder, and " "what the geocoded point (latitude and longitude) is based from. This " "should not be very different from the address field (if it is, you " "may need to change the address so the geocoder matches to the " "proper location).")) geocoded_point = models.PointField(null=True, blank=True, srid=4326) catchment_area = models.MultiPolygonField(null=True, blank=True, srid=4326) objects = TenantResourceManager() def __str__(self): return self.name def update_geocoded_info(self): results = geocoding.search(self.address) if results: result = results[0] self.geocoded_address = result.properties.label longitude, latitude = result.geometry.coordinates self.geocoded_point = Point(longitude, latitude) else: self.geocoded_address = '' self.geocoded_point = None def iter_geometries(self) -> Iterator[MultiPolygon]: regions = itertools.chain( self.zipcodes.all(), self.boroughs.all(), self.neighborhoods.all(), self.community_districts.all(), ) for region in regions: yield region.geom def update_catchment_area(self): self.catchment_area = union_geometries(self.iter_geometries()) def save(self, *args, **kwargs): if self.address != self.geocoded_address or not self.geocoded_point: self.update_geocoded_info() super().save(*args, **kwargs)
class JustfixUser(AbstractUser): phone_number = models.CharField( 'Phone number', unique=True, **pn.get_model_field_kwargs() ) objects = JustfixUserManager() USERNAME_FIELD = 'phone_number' REQUIRED_FIELDS = ['username', 'email'] @property def full_name(self) -> str: if self.first_name and self.last_name: return ' '.join([self.first_name, self.last_name]) return '' def formatted_phone_number(self) -> str: return pn.humanize(self.phone_number) @property def can_we_sms(self) -> bool: return hasattr(self, 'onboarding_info') and self.onboarding_info.can_we_sms def _log_sms(self): if self.can_we_sms: logging.info(f"Sending a SMS to user {self.username}.") else: logging.info(f"Not sending a SMS to user {self.username} because they opted out.") def send_sms(self, body: str, fail_silently=True) -> str: self._log_sms() if self.can_we_sms: return twilio.send_sms(self.phone_number, body, fail_silently=fail_silently) return '' def send_sms_async(self, body: str) -> None: self._log_sms() if self.can_we_sms: twilio.send_sms_async(self.phone_number, body) def trigger_followup_campaign_async(self, campaign_name: str) -> None: from rapidpro import followup_campaigns as fc fc.ensure_followup_campaign_exists(campaign_name) if self.can_we_sms: logging.info(f"Triggering rapidpro campaign '{campaign_name}' on user " f"{self.username}.") fc.trigger_followup_campaign_async( self.full_name, self.phone_number, campaign_name ) else: logging.info( f"Not triggering rapidpro campaign '{campaign_name}' on user " f"{self.username} because they opted out." ) @property def admin_url(self): return absolute_reverse('admin:users_justfixuser_change', args=[self.pk]) def __str__(self): if self.username: return self.username return '<unnamed user>'
class PhoneNumberLookup(models.Model): """ Information looked-up about a phone number via Twilio. """ phone_number = models.CharField(unique=True, **pn.get_model_field_kwargs()) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) is_valid = models.BooleanField(help_text="Whether Twilio thinks the phone number is valid.") carrier = models.JSONField( default=None, null=True, help_text=( "Carrier information about the phone number. This is in the format " 'specified in <a href="https://www.twilio.com/docs/lookup/api#lookups-carrier-info">' "Twilio's carrier information documentation</a>, though the keys are in snake-case " "rather than camel-case. This can be None if carrier info has not been looked up." ), ) objects = PhoneNumberLookupManager() def save(self, *args, **kwargs): """ Save the model, but first attempt to fetch carrier information if possible. """ from .twilio import get_carrier_info if self.carrier is None and self.is_valid: self.carrier = get_carrier_info(self.phone_number) super().save(*args, **kwargs) @property def validity_str(self) -> str: """ Return an adjective describing the validity of the phone number. """ if self.is_valid is True: return "valid" elif self.is_valid is False: return "invalid" return "unknown" @property def carrier_type(self) -> str: """ Return the carrier type of the phone number, or the empty string if it's not available. Valid carrier types include 'landline', 'mobile', and 'voip'. """ ctype = self.carrier and self.carrier.get("type") if ctype: return ctype return "" @property def adjectives(self) -> str: """ Return a set of adjectives describing the type, e.g.: >>> PhoneNumberLookup().adjectives 'unknown' >>> PhoneNumberLookup(is_valid=True, carrier={'type': 'mobile'}).adjectives 'valid mobile' """ return join_words(self.validity_str, self.carrier_type) @property def indefinite_article_with_adjectives(self) -> str: """ Return an indefinite article with adjectives describing the type, e.g.: >>> PhoneNumberLookup().indefinite_article_with_adjectives 'an unknown' >>> PhoneNumberLookup(is_valid=True).indefinite_article_with_adjectives 'a valid' """ adjs = self.adjectives article = "an" if adjs[0] in "aeiou" else "a" return f"{article} {adjs}" def __str__(self) -> str: """ Return a description of the lookup, e.g.: >>> str(PhoneNumberLookup()) 'unknown phone number' >>> str(PhoneNumberLookup(is_valid=True, phone_number='5551234567')) 'valid phone number 5551234567' """ return join_words(self.adjectives, "phone number", self.phone_number)
class LandlordDetails(MailingAddress): """ This represents the landlord details for a user's address, either manually entered by them or automatically looked up by us (or a combination of the two, if the user decided to change what we looked up). """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.__address_tracker = InstanceChangeTracker( self, self.MAILING_ADDRESS_ATTRS) user = models.OneToOneField( JustfixUser, on_delete=models.CASCADE, related_name="landlord_details", help_text="The user whose landlord details this is for.", ) name = models.CharField(blank=True, max_length=100, help_text="The landlord's name.") address = models.CharField( blank=True, max_length=ADDR_LENGTH, verbose_name="LEGACY address", help_text=( "The full mailing address for the landlord. This is a LEGACY " "field that we prefer not to use if possible, e.g. if the " "more granular primary/secondary line and city/state/zip " "details are available on this model."), ) lookup_date = models.DateField( null=True, blank=True, help_text="When we last tried to look up the landlord's details.") email = models.EmailField( blank=True, help_text="The landlord's email address.", ) phone_number = models.CharField( blank=True, **pn.get_model_field_kwargs(), ) is_looked_up = models.BooleanField( default=False, help_text=("Whether the name and address was looked up automatically, " "or manually entered by the user."), ) def formatted_phone_number(self) -> str: return pn.humanize(self.phone_number) def get_or_create_address_details_model(self) -> "AddressDetails": return AddressDetails.objects.get_or_create( address=self.address, defaults=self.get_address_as_dict(), )[0] @property def address_lines_for_mailing(self) -> List[str]: """ Return the full mailing address as a list of lines, preferring the new granular address information over the legacy "blobby" address information. """ value = super().address_lines_for_mailing if value: return value if not self.address: return [] return self.address.split("\n") def clear_address(self): super().clear_address() self.address = "" self.is_looked_up = False @classmethod def _get_or_create_for_user(cls, user: JustfixUser) -> "LandlordDetails": if hasattr(user, "landlord_details"): return user.landlord_details return LandlordDetails(user=user) @classmethod def create_or_update_lookup_for_user( cls, user: JustfixUser, save: bool = True) -> Optional["LandlordDetails"]: """ Create or update an instance of this class associated with the user by attempting to look up details on the given user's address. If the lookup fails, this method will still set the lookup date, so that another lookup can be attempted later. However, if the user doesn't have any address information, this will return None, as it has no address to lookup the landlord for. """ if hasattr(user, "onboarding_info") and user.onboarding_info.borough: oi = user.onboarding_info info = lookup_landlord(oi.full_nyc_address, oi.pad_bbl, oi.pad_bin) details = cls._get_or_create_for_user(user) details.lookup_date = timezone.now() if info: details.name = info.name details.address = info.address details.primary_line = info.primary_line details.city = info.city details.state = info.state details.zip_code = info.zip_code details.is_looked_up = True if save: details.save() return details return None def save(self, *args, **kwargs): if self.__address_tracker.has_changed(): # Update the legacy address field. self.address = "\n".join(self.address_lines_for_mailing) self.__address_tracker.set_to_unchanged() return super().save(*args, **kwargs)