Example #1
0
class Request(db.Model):  # type: ignore
    __tablename__ = "requests"

    query_class = RequestQuery

    zipcode_id = db.Column(
        db.Integer(),
        db.ForeignKey("zipcodes.id", name="requests_zipcode_id_fkey"),
        nullable=False,
        primary_key=True,
    )
    client_id = db.Column(
        db.Integer(),
        db.ForeignKey("clients.id", name="requests_client_id_fkey"),
        nullable=False,
        primary_key=True,
    )
    count = db.Column(db.Integer, nullable=False)
    first_ts = db.Column(db.Integer, nullable=False)
    last_ts = db.Column(db.Integer, nullable=False)

    client = db.relationship("Client")
    zipcode = db.relationship("Zipcode")

    def __repr__(self) -> str:
        return f"<Request {self.client_id} - {self.zipcode_id}>"
Example #2
0
class Event(db.Model):  # type: ignore
    __tablename__ = "events"

    query_class = EventQuery

    id = db.Column(db.Integer(), primary_key=True)
    client_id = db.Column(
        db.Integer(),
        db.ForeignKey("clients.id", name="events_client_id_fkey"),
        nullable=False,
        index=True,
    )
    type_code = db.Column(db.Integer(), nullable=False, index=True)
    timestamp = db.Column(db.TIMESTAMP(timezone=True),
                          nullable=False,
                          default=clock.now,
                          index=True)
    json_data = db.Column(db.JSON(), nullable=False)

    def __repr__(self) -> str:
        return f"<Event {self.type_code}>"

    @property
    def data(self) -> typing.Dict[str, typing.Any]:
        if not hasattr(self, "_data"):
            self._data = self.validate()
        return self._data

    def _get_schema(self) -> EventSchema:
        if self.type_code == EventType.QUALITY:
            return QualityEventSchema
        elif self.type_code == EventType.DETAILS:
            return DetailsEventSchema
        elif self.type_code == EventType.LAST:
            return QualityEventSchema
        elif self.type_code == EventType.MENU:
            return EmptySchema
        elif self.type_code == EventType.ABOUT:
            return EmptySchema
        elif self.type_code == EventType.UNSUBSCRIBE:
            return SubscribeEventSchema
        elif self.type_code == EventType.ALERT:
            return AlertEventSchema
        elif self.type_code == EventType.FEEDBACK_BEGIN:
            return EmptySchema
        elif self.type_code == EventType.FEEDBACK_RECEIVED:
            return FeedbackReceivedEventSchema
        elif self.type_code == EventType.RESUBSCRIBE:
            return SubscribeEventSchema
        elif self.type_code == EventType.UNSUBSCRIBE_AUTO:
            return SubscribeEventSchema
        elif self.type_code == EventType.SHARE_REQUEST:
            return EmptySchema
        else:
            raise Exception(f"Unknown event type {self.type_code}")

    def validate(self) -> typing.Dict[str, typing.Any]:
        schema = self._get_schema()
        return dataclasses.asdict(schema(**self.json_data))
Example #3
0
class City(db.Model):  # type: ignore
    __tablename__ = "cities"

    id = db.Column(db.Integer(), nullable=False, primary_key=True)
    name = db.Column(db.String(), nullable=False)
    state_code = db.Column(db.String(2), nullable=False)

    zipcodes = db.relationship("Zipcode")

    __table_args__ = (db.Index(
        "_name_state_code_index",
        "name",
        "state_code",
        unique=True,
    ), )
Example #4
0
class SensorZipcodeRelation(db.Model):  # type: ignore
    __tablename__ = "sensors_zipcodes"

    sensor_id = db.Column(db.Integer(),
                          db.ForeignKey("sensors.id"),
                          nullable=False,
                          primary_key=True)
    zipcode_id = db.Column(db.Integer(),
                           db.ForeignKey("zipcodes.id"),
                           nullable=False,
                           primary_key=True)
    distance = db.Column(db.Float(), nullable=False)

    def __repr__(self) -> str:
        return f"<SensorZipcodeRelation {self.sensor_id} - {self.zipcode_id} - {self.distance}>"
Example #5
0
class User(UserMixin, db.Model):  # type: ignore
    __tablename__ = "users"

    id = db.Column(db.Integer(), primary_key=True)
    email = db.Column(db.String(120), index=True, unique=True, nullable=False)
    password_hash = db.Column(db.String(128), nullable=False)
    is_admin = db.Column(db.Boolean(), default=False)

    def __repr__(self):
        return f"<User {self.email}>"

    def set_password(self, password: str):
        self.password_hash = generate_password_hash(password)

    def check_password(self, password: str) -> bool:
        return check_password_hash(self.password_hash, password)

    @property
    def can_send_sms(self) -> bool:
        return self.email == "*****@*****.**"
Example #6
0
class Client(db.Model):  # type: ignore
    __tablename__ = "clients"

    query_class = ClientQuery

    id = db.Column(db.Integer(), primary_key=True)
    identifier = db.Column(db.String(), nullable=False)
    type_code = db.Column(db.Enum(ClientIdentifierType), nullable=False)
    created_at = db.Column(db.TIMESTAMP(timezone=True),
                           default=now,
                           index=True,
                           nullable=False)
    last_activity_at = db.Column(db.Integer(),
                                 nullable=False,
                                 index=True,
                                 server_default="0")

    zipcode_id = db.Column(
        db.Integer(),
        db.ForeignKey("zipcodes.id", name="clients_zipcode_id_fkey"),
        nullable=True,
    )

    last_pm25 = db.Column(db.Float(), nullable=True)
    last_humidity = db.Column(db.Float(), nullable=True)
    last_pm_cf_1 = db.Column(db.Float(), nullable=True)

    last_alert_sent_at = db.Column(db.Integer(),
                                   nullable=False,
                                   index=True,
                                   server_default="0")
    alerts_disabled_at = db.Column(db.Integer(),
                                   nullable=False,
                                   index=True,
                                   server_default="0")
    num_alerts_sent = db.Column(db.Integer(),
                                nullable=False,
                                server_default="0")
    locale = db.Column(db.String(), nullable=False, server_default="en")

    preferences = db.Column(db.JSON(), nullable=True)

    zipcode = db.relationship("Zipcode")
    events = db.relationship("Event")

    __table_args__ = (db.Index(
        "_client_identifier_type_code",
        "identifier",
        "type_code",
        unique=True,
    ), )

    def __repr__(self) -> str:
        return f"<Client {self.identifier}>"

    # Send alerts between 8 AM and 9 PM.
    SEND_WINDOW_HOURS = (8, 21)

    # Time after which the client shouldn't treat an event as "recent"
    # and therefore shouldn't include it in its state
    EVENT_RESPONSE_TIME = datetime.timedelta(hours=1)

    @classmethod
    def get_share_window(self) -> typing.Tuple[int, int]:
        ts = timestamp()
        share_window_start = ts - 60 * 15
        share_window_end = ts - 60 * 5
        return share_window_start, share_window_end

    @classmethod
    def get_share_request_cutoff(self) -> datetime.datetime:
        return now() - datetime.timedelta(days=60)

    #
    # Presence
    #

    def mark_seen(self, locale: str):
        self.last_activity_at = timestamp()
        if self.locale != locale:
            self.locale = locale
        db.session.commit()

    #
    # Prefs
    #

    alert_frequency = IntegerPreference(
        display_name=lazy_gettext("Alert Frequency"),
        description=lazy_gettext(
            "By default, Hazebot sends alerts at most every 2 hours."),
        default=2,
        min_value=0,
        max_value=24,
    )

    alert_threshold: IntegerChoicesPreference[Pm25] = IntegerChoicesPreference(
        display_name=lazy_gettext("Alert Threshold"),
        description=lazy_gettext(
            "AQI category below which Hazebot won't send alerts.\n"
            "For example, if you set this to MODERATE, "
            "Hazebot won't send alerts when AQI transitions from GOOD to MODERATE or from MODERATE to GOOD."
        ),
        default=Pm25.MODERATE,
        choices=Pm25,
    )

    conversion_factor: StringChoicesPreference[
        ConversionFactor] = StringChoicesPreference(
            display_name=lazy_gettext("Conversion Factor"),
            description=lazy_gettext(
                "Conversion factor to use when calculating AQI. "
                "For more details, see https://www2.purpleair.com/community/faq#hc-should-i-use-the-conversion-factors-on-the-purpleair-map-1."
            ),
            default=ConversionFactor.NONE,
            choices=ConversionFactor,
        )

    #
    # AQI
    #

    def get_last_readings(self) -> Readings:
        return Readings(pm25=self.last_pm25,
                        pm_cf_1=self.last_pm_cf_1,
                        humidity=self.last_humidity)

    def get_current_aqi(self) -> int:
        """Current AQI for this client."""
        return self.zipcode.get_aqi(self.conversion_factor)

    def get_current_pm25(self) -> float:
        """Current Pm25 for this client as determined by its chosen strategy."""
        return self.zipcode.get_pm25(self.conversion_factor)

    def get_current_pm25_level(self) -> Pm25:
        """Current Pm25 level for this client as determined by its chosen strategy."""
        return self.zipcode.get_pm25_level(self.conversion_factor)

    def get_last_aqi(self) -> int:
        """Last AQI at which an alert was sent to this client."""
        return self.get_last_readings().get_aqi(self.conversion_factor)

    def get_last_pm25(self) -> float:
        """Last Pm25 for this client as determined by its chosen strategy."""
        return self.get_last_readings().get_pm25(self.conversion_factor)

    def get_last_pm25_level(self) -> Pm25:
        """Last Pm25 level for this client as determined by its chosen strategy."""
        return self.get_last_readings().get_pm25_level(self.conversion_factor)

    def get_recommendations(self, num_desired: int) -> typing.List[Zipcode]:
        """Recommended zipcodes for this client."""
        return self.zipcode.get_recommendations(num_desired,
                                                self.conversion_factor)

    #
    # Alerting
    #

    @property
    def is_enabled_for_alerts(self) -> bool:
        return bool(self.zipcode_id and not self.alerts_disabled_at)

    def update_subscription(self, zipcode: Zipcode) -> bool:
        self.last_pm25 = zipcode.pm25
        self.last_humidity = zipcode.humidity
        self.last_pm_cf_1 = zipcode.pm_cf_1

        curr_zipcode_id = self.zipcode_id
        self.zipcode_id = zipcode.id

        db.session.commit()

        return curr_zipcode_id != self.zipcode_id

    def disable_alerts(self, is_automatic=False):
        if self.alerts_disabled_at == 0:
            self.alerts_disabled_at = timestamp()
            db.session.commit()
            self.log_event(
                EventType.UNSUBSCRIBE_AUTO
                if is_automatic else EventType.UNSUBSCRIBE,
                zipcode=self.zipcode.zipcode,
            )

    def enable_alerts(self):
        if self.alerts_disabled_at > 0:
            self.last_pm25 = self.zipcode.pm25
            self.last_humidity = self.zipcode.humidity
            self.last_pm_cf_1 = self.zipcode.pm_cf_1
            self.alerts_disabled_at = 0
            db.session.commit()
            self.log_event(EventType.RESUBSCRIBE, zipcode=self.zipcode.zipcode)

    @property
    def is_in_send_window(self) -> bool:
        if self.zipcode_id is None:
            return False
        # Timezone can be null since our data is incomplete.
        timezone = self.zipcode.timezone or "America/Los_Angeles"
        dt = now(timezone=timezone)
        send_start, send_end = self.SEND_WINDOW_HOURS
        return send_start <= dt.hour < send_end

    def send_message(self,
                     message: str,
                     media: typing.Optional[str] = None) -> bool:
        if self.type_code == ClientIdentifierType.PHONE_NUMBER:
            try:
                send_sms(message, self.identifier, self.locale, media=media)
            except TwilioRestException as e:
                code = TwilioErrorCode.from_exc(e)
                if code:
                    logger.warning(
                        "Disabling alerts for recipient %s: %s",
                        self,
                        code.name,
                    )
                    self.disable_alerts(is_automatic=True)
                    return False
                else:
                    raise
        else:
            # Other clients types don't yet support message sending.
            logger.info("Not messaging client %s: %s", self.id, message)

        return True

    def maybe_notify(self) -> bool:
        if not self.is_in_send_window:
            return False

        alert_frequency = self.alert_frequency * 60 * 60
        if self.last_alert_sent_at >= timestamp() - alert_frequency:
            return False

        curr_pm25 = self.get_current_pm25()
        curr_aqi_level = self.get_current_pm25_level()
        curr_aqi = self.get_current_aqi()

        # Only send if the pm25 changed a level since the last time we sent this alert.
        last_aqi_level = self.get_last_pm25_level()
        if curr_aqi_level == last_aqi_level:
            return False

        alert_threshold = self.alert_threshold

        # If the current AQI is below the alert threshold, and the last AQI was
        # at the alerting threshold or below, we won't send the alert.
        # For example, if the user sets their threshold at UNHEALTHY, they won't
        # be notified when the AQI transitions from UNHEALTHY to UNHEALHY FOR SENSITIVE GROUPS
        # or from UNHEALTHY to MODERATE, but will be notified if the AQI transitions from
        # VERY UNHEALTHY to UNHEALTHY.
        if curr_aqi_level < alert_threshold and last_aqi_level <= alert_threshold:
            return False

        # If the current AQI is at the alert threshold but the last AQI was under it,
        # don't send the alert because we haven't crossed the threshold yet.
        if curr_aqi_level == alert_threshold and last_aqi_level < alert_threshold:
            return False

        # Do not alert clients who received an alert recently unless AQI has changed markedly.
        was_alerted_recently = self.last_alert_sent_at > timestamp() - (60 *
                                                                        60 * 6)
        last_aqi = self.get_last_aqi()
        if was_alerted_recently and abs(curr_aqi - last_aqi) < 50:
            return False

        message = gettext(
            'AQI is now %(curr_aqi)s in zipcode %(zipcode)s (level: %(curr_aqi_level)s).\n\nReply "M" for Menu or "E" to end alerts.',
            zipcode=self.zipcode.zipcode,
            curr_aqi_level=curr_aqi_level.display,
            curr_aqi=curr_aqi,
        )
        if not self.send_message(message):
            return False

        self.last_alert_sent_at = timestamp()
        self.last_pm25 = self.zipcode.pm25
        self.last_pm_cf_1 = self.zipcode.pm_cf_1
        self.last_humidity = self.zipcode.humidity
        self.num_alerts_sent += 1
        db.session.commit()

        self.log_event(EventType.ALERT,
                       zipcode=self.zipcode.zipcode,
                       pm25=curr_pm25)

        return True

    def request_share(self) -> bool:
        if not self.is_in_send_window:
            return False

        if self.created_at >= now() - datetime.timedelta(days=7):
            return False

        # Double check that we're all good to go
        share_window_start, share_window_end = self.get_share_window()
        if (not self.last_alert_sent_at
                or self.last_alert_sent_at <= share_window_start
                or self.last_alert_sent_at >= share_window_end):
            return False

        # Check the last share request we sent was a long time ago
        share_request = self.get_last_share_request()
        if share_request and share_request.timestamp >= self.get_share_request_cutoff(
        ):
            return False

        message = gettext(
            "Has Hazebot been helpful? We’re looking for ways to grow and improve, and we’d love your help. Save our contact and share Hazebot with a friend, or text “feedback” to send feedback."
        )
        if not self.send_message(message):
            return False

        Event.query.create(self.id, EventType.SHARE_REQUEST)
        return True

    #
    # Events
    #

    def log_event(self, event_type: EventType,
                  **event_data: typing.Any) -> Event:
        return Event.query.create(self.id, event_type, **event_data)

    def _get_last_event_by_type(
            self, event_type: EventType) -> typing.Optional[Event]:
        return (Event.query.filter(Event.client_id == self.id).filter(
            Event.type_code == event_type).order_by(
                Event.timestamp.desc()).first())

    def get_last_alert(self) -> typing.Optional[Event]:
        return self._get_last_event_by_type(EventType.ALERT)

    def get_last_share_request(self) -> typing.Optional[Event]:
        return self._get_last_event_by_type(EventType.SHARE_REQUEST)

    def get_last_client_event(self) -> typing.Optional[Event]:
        return (Event.query.filter(Event.client_id == self.id).filter(
            ~Event.type_code.in_([EventType.ALERT, EventType.SHARE_REQUEST
                                  ]))  # filter for only inbound events
                .order_by(Event.timestamp.desc()).first())

    def get_last_client_event_type(self) -> typing.Optional[EventType]:
        last_event = self.get_last_client_event()
        if last_event:
            return EventType(last_event.type_code)
        return None

    def should_accept_feedback(self) -> bool:
        # First check if the most recent event is a feedback begin or unsub event
        if self.has_recent_last_events_of_type({
                EventType.FEEDBACK_BEGIN,
                EventType.UNSUBSCRIBE,
        }):
            return True

        # Then check if we have an outstanding feedback request
        cutoff = now() - datetime.timedelta(days=4)
        feedback_request_event = self.get_event_of_type_after(
            EventType.FEEDBACK_REQUEST, cutoff)
        # Check whether feedback was responded to
        if feedback_request_event and not self.get_event_of_type_after(
                EventType.FEEDBACK_RECEIVED, feedback_request_event.timestamp):
            return True

        return False

    def get_event_of_type_after(
            self, event_type: EventType,
            cutoff: datetime.datetime) -> typing.Optional[Event]:
        return (Event.query.filter(Event.client_id == self.id).filter(
            Event.timestamp > cutoff).filter(
                Event.type_code == event_type).first())

    def has_recent_last_events_of_type(
            self, event_types: typing.Set[EventType]) -> bool:
        return any(self.has_recent_last_event_of_type(e) for e in event_types)

    def has_recent_last_event_of_type(self, event_type: EventType) -> bool:
        last_event = self.get_last_client_event()
        return bool(
            last_event and last_event.type_code == event_type
            and now() - last_event.timestamp < Client.EVENT_RESPONSE_TIME)
Example #7
0
class Zipcode(db.Model):  # type: ignore
    __tablename__ = "zipcodes"

    query_class = ZipcodeQuery

    id = db.Column(db.Integer(), nullable=False, primary_key=True)
    zipcode = db.Column(db.String(), nullable=False, unique=True, index=True)
    city_id = db.Column(db.Integer(),
                        db.ForeignKey("cities.id"),
                        nullable=False)
    latitude = db.Column(db.Float(asdecimal=True), nullable=False)
    longitude = db.Column(db.Float(asdecimal=True), nullable=False)
    timezone = db.Column(db.String(), nullable=True)

    geohash_bit_1 = db.Column(db.String(), nullable=False)
    geohash_bit_2 = db.Column(db.String(), nullable=False)
    geohash_bit_3 = db.Column(db.String(), nullable=False)
    geohash_bit_4 = db.Column(db.String(), nullable=False)
    geohash_bit_5 = db.Column(db.String(), nullable=False)
    geohash_bit_6 = db.Column(db.String(), nullable=False)
    geohash_bit_7 = db.Column(db.String(), nullable=False)
    geohash_bit_8 = db.Column(db.String(), nullable=False)
    geohash_bit_9 = db.Column(db.String(), nullable=False)
    geohash_bit_10 = db.Column(db.String(), nullable=False)
    geohash_bit_11 = db.Column(db.String(), nullable=False)
    geohash_bit_12 = db.Column(db.String(), nullable=False)
    coordinates = db.Column(Geometry("POINT"), nullable=True)

    pm25 = db.Column(db.Float(),
                     nullable=False,
                     index=True,
                     server_default="0")
    humidity = db.Column(db.Float(), nullable=False, server_default="0")
    pm_cf_1 = db.Column(db.Float(), nullable=False, server_default="0")
    pm25_updated_at = db.Column(db.Integer(),
                                nullable=False,
                                index=True,
                                server_default="0")

    metrics_data = db.Column(db.JSON(), nullable=True)

    city = db.relationship("City")

    def __repr__(self) -> str:
        return f"<Zipcode {self.zipcode}>"

    def __new__(cls, *args, **kwargs):
        obj = super().__new__(cls)
        obj._distance_cache = {}
        return obj

    def get_metrics(self) -> ZipcodeMetrics:
        if not hasattr(self, "_metrics"):
            self._metrics = ZipcodeMetrics(
                num_sensors=self.metrics_data["num_sensors"],
                max_sensor_distance=self.metrics_data["max_sensor_distance"],
                min_sensor_distance=self.metrics_data["min_sensor_distance"],
                sensor_ids=self.metrics_data["sensor_ids"],
            )
        return self._metrics

    def get_readings(self) -> Readings:
        return Readings(pm25=self.pm25,
                        pm_cf_1=self.pm_cf_1,
                        humidity=self.humidity)

    @property
    def num_sensors(self) -> int:
        return self.get_metrics().num_sensors

    @property
    def max_sensor_distance(self) -> int:
        return self.get_metrics().max_sensor_distance

    @property
    def min_sensor_distance(self) -> int:
        return self.get_metrics().min_sensor_distance

    @property
    def geohash(self) -> str:
        """This zipcode's geohash."""
        return "".join(
            [getattr(self, f"geohash_bit_{i}") for i in range(1, 13)])

    @classmethod
    def pm25_stale_cutoff(cls) -> float:
        """Timestamp before which pm25 measurements are considered stale."""
        return timestamp() - (60 * 60)

    @property
    def is_pm25_stale(self) -> bool:
        """Whether this zipcode's pm25 measurements are considered stale."""
        return self.pm25_updated_at < self.pm25_stale_cutoff()

    def distance(self, other: "Zipcode") -> float:
        """Distance between this zip and the given zip."""
        if other.id in self._distance_cache:
            return self._distance_cache[other.id]
        if self.id in other._distance_cache:
            return other._distance_cache[self.id]
        self._distance_cache[other.id] = haversine_distance(
            other.longitude,
            other.latitude,
            self.longitude,
            self.latitude,
        )
        return self._distance_cache[other.id]

    def get_aqi(self, conversion_factor: ConversionFactor) -> int:
        """The AQI for this zipcode (e.g., 35) as determined by the provided strategy."""
        return self.get_readings().get_aqi(conversion_factor)

    def get_pm25(self, conversion_factor: ConversionFactor) -> float:
        """Current pm25 for this client, as determined by the provided strategy."""
        return self.get_readings().get_pm25(conversion_factor)

    def get_pm25_level(self, conversion_factor: ConversionFactor) -> Pm25:
        """The pm25 category for this zipcode (e.g., Moderate)."""
        return self.get_readings().get_pm25_level(conversion_factor)

    def get_recommendations(
            self, num_desired: int,
            conversion_factor: ConversionFactor) -> typing.List["Zipcode"]:
        """Get n recommended zipcodes near this zipcode, sorted by distance."""
        if self.is_pm25_stale:
            return []

        cutoff = self.pm25_stale_cutoff()

        # TODO: Make this faster somehow?
        curr_pm25_level = self.get_pm25_level(conversion_factor)
        zipcodes = [
            z for z in Zipcode.query.filter(
                Zipcode.pm25_updated_at > cutoff).order_by_distance(self)
            if z.get_pm25_level(conversion_factor) < curr_pm25_level
        ]

        return zipcodes[:num_desired]
Example #8
0
class Sensor(db.Model):  # type: ignore
    __tablename__ = "sensors"

    id = db.Column(db.Integer(), nullable=False, primary_key=True)
    latest_reading = db.Column(db.Float(), nullable=False)
    updated_at = db.Column(db.Integer(), nullable=False)
    latitude = db.Column(db.Float(), nullable=False)
    longitude = db.Column(db.Float(), nullable=False)
    geohash_bit_1 = db.Column(db.String(), nullable=False)
    geohash_bit_2 = db.Column(db.String(), nullable=False)
    geohash_bit_3 = db.Column(db.String(), nullable=False)
    geohash_bit_4 = db.Column(db.String(), nullable=False)
    geohash_bit_5 = db.Column(db.String(), nullable=False)
    geohash_bit_6 = db.Column(db.String(), nullable=False)
    geohash_bit_7 = db.Column(db.String(), nullable=False)
    geohash_bit_8 = db.Column(db.String(), nullable=False)
    geohash_bit_9 = db.Column(db.String(), nullable=False)
    geohash_bit_10 = db.Column(db.String(), nullable=False)
    geohash_bit_11 = db.Column(db.String(), nullable=False)
    geohash_bit_12 = db.Column(db.String(), nullable=False)

    def __repr__(self) -> str:
        return f"<Sensor {self.id}: {self.latest_reading}>"

    @property
    def geohash(self) -> str:
        return "".join(
            [getattr(self, f"geohash_bit_{i}") for i in range(1, 13)])
Example #9
0
class Client(db.Model):  # type: ignore
    __tablename__ = "clients"

    query_class = ClientQuery

    id = db.Column(db.Integer(), primary_key=True)
    identifier = db.Column(db.String(), nullable=False)
    type_code = db.Column(db.Enum(ClientIdentifierType), nullable=False)
    created_at = db.Column(db.TIMESTAMP(timezone=True),
                           default=now,
                           index=True,
                           nullable=False)
    last_activity_at = db.Column(db.Integer(),
                                 nullable=False,
                                 index=True,
                                 server_default="0")

    zipcode_id = db.Column(
        db.Integer(),
        db.ForeignKey("zipcodes.id", name="clients_zipcode_id_fkey"),
        nullable=True,
    )
    last_pm25 = db.Column(db.Float(), nullable=True)
    last_alert_sent_at = db.Column(db.Integer(),
                                   nullable=False,
                                   index=True,
                                   server_default="0")
    alerts_disabled_at = db.Column(db.Integer(),
                                   nullable=False,
                                   index=True,
                                   server_default="0")
    num_alerts_sent = db.Column(db.Integer(),
                                nullable=False,
                                server_default="0")

    requests = db.relationship("Request")
    zipcode = db.relationship("Zipcode")
    events = db.relationship("Event")

    __table_args__ = (db.Index(
        "_client_identifier_type_code",
        "identifier",
        "type_code",
        unique=True,
    ), )

    def __repr__(self) -> str:
        return f"<Client {self.identifier}>"

    # Send alerts at most every TWO hours to avoid spamming people.
    # One hour seems like a reasonable frequency because AQI
    # doesn't fluctuate very frequently. We should look at implementing
    # logic to smooth out this alerting so that if AQI oscillates
    # between two levels we don't spam the user every TWO hour.
    # TODO: update logic with hysteresis to avoid spamming + save money
    FREQUENCY = 2 * 60 * 60

    # Send alerts between 8 AM and 9 PM.
    SEND_WINDOW_HOURS = (8, 21)

    # Time alotted between feedback command and feedback response.
    FEEDBACK_RESPONSE_TIME = datetime.timedelta(hours=1)

    @classmethod
    def get_share_window(self) -> typing.Tuple[int, int]:
        ts = timestamp()
        share_window_start = ts - 60 * 15
        share_window_end = ts - 60 * 5
        return share_window_start, share_window_end

    @classmethod
    def get_share_request_cutoff(self) -> datetime.datetime:
        return now() - datetime.timedelta(days=60)

    #
    # Presence
    #

    def log_request(self, zipcode: Zipcode):
        request = Request.query.filter_by(
            client_id=self.id,
            zipcode_id=zipcode.id,
        ).first()
        now = timestamp()
        if request is None:
            request = Request(
                client_id=self.id,
                zipcode_id=zipcode.id,
                count=1,
                first_ts=now,
                last_ts=now,
            )
            db.session.add(request)
        else:
            request.count += 1
            request.last_ts = now
        db.session.commit()

    def mark_seen(self):
        self.last_activity_at = timestamp()
        db.session.commit()

    #
    # AQI
    #

    @property
    def last_aqi(self) -> typing.Optional[int]:
        return pm25_to_aqi(self.last_pm25)

    #
    # Alerting
    #

    @property
    def is_enabled_for_alerts(self) -> bool:
        return bool(self.zipcode_id and not self.alerts_disabled_at)

    def update_subscription(self, zipcode: Zipcode) -> bool:
        self.last_pm25 = zipcode.pm25
        curr_zipcode_id = self.zipcode_id
        self.zipcode_id = zipcode.id
        db.session.commit()
        return curr_zipcode_id != self.zipcode_id

    def disable_alerts(self, is_automatic=False):
        if self.alerts_disabled_at == 0:
            self.alerts_disabled_at = timestamp()
            db.session.commit()
            self.log_event(
                EventType.UNSUBSCRIBE_AUTO
                if is_automatic else EventType.UNSUBSCRIBE,
                zipcode=self.zipcode.zipcode,
            )

    def enable_alerts(self):
        if self.alerts_disabled_at > 0:
            self.last_pm25 = self.zipcode.pm25
            self.alerts_disabled_at = 0
            db.session.commit()
            self.log_event(EventType.RESUBSCRIBE, zipcode=self.zipcode.zipcode)

    @property
    def is_in_send_window(self) -> bool:
        if self.zipcode_id is None:
            return False
        # Timezone can be null since our data is incomplete.
        timezone = self.zipcode.timezone or "America/Los_Angeles"
        dt = now(timezone=timezone)
        send_start, send_end = self.SEND_WINDOW_HOURS
        return send_start <= dt.hour < send_end

    def send_message(self, message: str) -> bool:
        if self.type_code == ClientIdentifierType.PHONE_NUMBER:
            try:
                send_sms(message, self.identifier)
            except TwilioRestException as e:
                code = TwilioErrorCode.from_exc(e)
                if code:
                    logger.warning(
                        "Disabling alerts for recipient %s: %s",
                        self,
                        code.name,
                    )
                    self.disable_alerts(is_automatic=True)
                    return False
                else:
                    raise
        else:
            # Other clients types don't yet support message sending.
            logger.info("Not messaging client %s: %s", self.id, message)

        return True

    def maybe_notify(self) -> bool:
        if not self.is_in_send_window:
            return False

        if self.last_alert_sent_at >= timestamp() - self.FREQUENCY:
            return False

        curr_pm25 = self.zipcode.pm25
        curr_aqi_level = Pm25.from_measurement(curr_pm25)
        curr_aqi = pm25_to_aqi(curr_pm25)

        # Only send if the pm25 changed a level since the last time we sent this alert.
        last_aqi_level = Pm25.from_measurement(self.last_pm25)
        if curr_aqi_level == last_aqi_level:
            return False

        # Do not alert clients who received an alert recently unless AQI has changed markedly.
        was_alerted_recently = self.last_alert_sent_at > timestamp() - (60 *
                                                                        60 * 6)
        last_aqi = self.last_aqi
        if (was_alerted_recently and last_aqi and curr_aqi
                and abs(curr_aqi - last_aqi) < 20):
            return False

        message = (
            "Air quality in {city} {zipcode} has changed to {curr_aqi_level} (AQI {curr_aqi}).\n"
            "\n"
            'Reply "M" for Menu or "E" to end alerts.').format(
                city=self.zipcode.city.name,
                zipcode=self.zipcode.zipcode,
                curr_aqi_level=curr_aqi_level.display,
                curr_aqi=curr_aqi,
            )
        if not self.send_message(message):
            return False

        self.last_alert_sent_at = timestamp()
        self.last_pm25 = curr_pm25
        self.num_alerts_sent += 1
        db.session.commit()

        self.log_event(EventType.ALERT,
                       zipcode=self.zipcode.zipcode,
                       pm25=curr_pm25)

        return True

    def request_share(self) -> bool:
        if not self.is_in_send_window:
            return False

        if self.created_at >= now() - datetime.timedelta(days=7):
            return False

        # Double check that we're all good to go
        share_window_start, share_window_end = self.get_share_window()
        if (not self.last_alert_sent_at
                or self.last_alert_sent_at <= share_window_start
                or self.last_alert_sent_at >= share_window_end):
            return False

        # Check the last share request we sent was a long time ago
        share_request = self.get_last_share_request()
        if share_request and share_request.timestamp >= self.get_share_request_cutoff(
        ):
            return False

        message = (
            "Has Hazebot been helpful? "
            "We’re looking for ways to grow and improve, and we’d love your help. "
            "Save our contact and share Hazebot with a friend, or text “feedback” to send feedback."
        )
        if not self.send_message(message):
            return False

        Event.query.create(self.id, EventType.SHARE_REQUEST)
        return True

    #
    # Events
    #

    def log_event(self, event_type: EventType,
                  **event_data: typing.Any) -> Event:
        return Event.query.create(self.id, event_type, **event_data)

    def _get_last_event_by_type(
            self, event_type: EventType) -> typing.Optional[Event]:
        return (Event.query.filter(Event.client_id == self.id).filter(
            Event.type_code == event_type).order_by(
                Event.timestamp.desc()).first())

    def get_last_alert(self) -> typing.Optional[Event]:
        return self._get_last_event_by_type(EventType.ALERT)

    def get_last_share_request(self) -> typing.Optional[Event]:
        return self._get_last_event_by_type(EventType.SHARE_REQUEST)

    def get_last_client_event(self) -> typing.Optional[Event]:
        return (Event.query.filter(Event.client_id == self.id).filter(
            ~Event.type_code.in_([EventType.ALERT, EventType.SHARE_REQUEST
                                  ]))  # filter for only inbound events
                .order_by(Event.timestamp.desc()).first())

    def get_last_client_event_type(self) -> typing.Optional[EventType]:
        last_event = self.get_last_client_event()
        if last_event:
            return EventType(last_event.type_code)
        return None

    def should_accept_feedback(self) -> bool:
        last_event = self.get_last_client_event()
        return bool(
            last_event and last_event.type_code
            in (EventType.FEEDBACK_BEGIN, EventType.UNSUBSCRIBE)
            and now() - last_event.timestamp < Client.FEEDBACK_RESPONSE_TIME)
Example #10
0
class Zipcode(db.Model):  # type: ignore
    __tablename__ = "zipcodes"

    query_class = ZipcodeQuery

    id = db.Column(db.Integer(), nullable=False, primary_key=True)
    zipcode = db.Column(db.String(), nullable=False, unique=True, index=True)
    city_id = db.Column(db.Integer(), db.ForeignKey("cities.id"), nullable=False)
    latitude = db.Column(db.Float(asdecimal=True), nullable=False)
    longitude = db.Column(db.Float(asdecimal=True), nullable=False)
    timezone = db.Column(db.String(), nullable=True)

    geohash_bit_1 = db.Column(db.String(), nullable=False)
    geohash_bit_2 = db.Column(db.String(), nullable=False)
    geohash_bit_3 = db.Column(db.String(), nullable=False)
    geohash_bit_4 = db.Column(db.String(), nullable=False)
    geohash_bit_5 = db.Column(db.String(), nullable=False)
    geohash_bit_6 = db.Column(db.String(), nullable=False)
    geohash_bit_7 = db.Column(db.String(), nullable=False)
    geohash_bit_8 = db.Column(db.String(), nullable=False)
    geohash_bit_9 = db.Column(db.String(), nullable=False)
    geohash_bit_10 = db.Column(db.String(), nullable=False)
    geohash_bit_11 = db.Column(db.String(), nullable=False)
    geohash_bit_12 = db.Column(db.String(), nullable=False)

    pm25 = db.Column(db.Float(), nullable=False, index=True, server_default="0")
    pm25_updated_at = db.Column(
        db.Integer(), nullable=False, index=True, server_default="0"
    )
    num_sensors = db.Column(db.Integer(), nullable=False, server_default="0")
    min_sensor_distance = db.Column(db.Float(), nullable=False, server_default="0")
    max_sensor_distance = db.Column(db.Float(), nullable=False, server_default="0")

    city = db.relationship("City")
    requests = db.relationship("Request")

    def __repr__(self) -> str:
        return f"<Zipcode {self.zipcode}>"

    def __new__(cls, *args, **kwargs):
        obj = super().__new__(cls)
        obj._distance_cache = {}
        return obj

    @property
    def geohash(self) -> str:
        """This zipcode's geohash."""
        return "".join([getattr(self, f"geohash_bit_{i}") for i in range(1, 13)])

    @property
    def aqi(self) -> typing.Optional[int]:
        """The AQI for this zipcode (e.g., 35)."""
        return pm25_to_aqi(self.pm25)

    @property
    def pm25_level(self) -> Pm25:
        """The pm25 category for this zipcode (e.g., Moderate)."""
        return Pm25.from_measurement(self.pm25)

    @classmethod
    def pm25_stale_cutoff(cls) -> float:
        """Timestamp before which pm25 measurements are considered stale."""
        return timestamp() - (60 * 30)

    @property
    def is_pm25_stale(self) -> bool:
        """Whether this zipcode's pm25 measurements are considered stale."""
        return self.pm25_updated_at < self.pm25_stale_cutoff()

    def distance(self, other: "Zipcode") -> float:
        """Distance between this zip and the given zip."""
        if other.id in self._distance_cache:
            return self._distance_cache[other.id]
        if self.id in other._distance_cache:
            return other._distance_cache[self.id]
        self._distance_cache[other.id] = haversine_distance(
            other.longitude,
            other.latitude,
            self.longitude,
            self.latitude,
        )
        return self._distance_cache[other.id]

    def get_recommendations(self, num_desired: int) -> typing.List["Zipcode"]:
        """Get n recommended zipcodes near this zipcode, sorted by distance."""
        if not self.pm25_level or self.is_pm25_stale:
            return []

        cutoff = self.pm25_stale_cutoff()
        zipcodes = (
            Zipcode.query.filter(Zipcode.pm25_updated_at > cutoff)
            .filter(Zipcode.pm25 < self.pm25_level)
            .all()
        )
        # Sorting 40000 zipcodes in memory is surprisingly fast.
        #
        # I wouldn't be surprised if doing this huge fetch every time actually leads to better
        # performance since Postgres can easily cache the whole query.
        #
        return sorted(zipcodes, key=lambda z: self.distance(z))[:num_desired]