Beispiel #1
0
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
Beispiel #2
0
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>"
Beispiel #3
0
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)
Beispiel #4
0
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>'
Beispiel #5
0
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)
Beispiel #6
0
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)