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)])
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}>"
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))
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}>"
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, ), )
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 == "*****@*****.**"
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)
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]
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)
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]