2024-01-13 17:13:38 -08:00
import io
2024-01-13 14:24:00 -08:00
from datetime import datetime
2024-01-13 15:01:11 -08:00
from typing import Optional
2024-01-31 21:34:58 -08:00
import mastodon
2024-01-13 14:24:00 -08:00
import requests
2024-04-20 15:03:16 -07:00
import shapely
2024-04-20 12:08:39 -07:00
import staticmap
2024-01-14 12:27:02 -08:00
import yaml
2024-01-13 14:24:00 -08:00
from mastodon import Mastodon
2024-01-13 17:13:38 -08:00
from PIL import Image , ImageDraw , ImageFont
2024-11-23 17:07:02 -08:00
from shapely import Geometry
2024-01-13 15:01:11 -08:00
from sqlalchemy import create_engine , select
2024-01-13 14:24:00 -08:00
from sqlalchemy . exc import NoResultFound
2024-01-13 15:01:11 -08:00
from sqlalchemy . orm import DeclarativeBase , Mapped , Session , mapped_column
2024-01-13 14:24:00 -08:00
2024-11-23 17:07:02 -08:00
from geospatial import DBGeometry , convert_outage_geometry
2024-01-13 14:24:00 -08:00
post_datetime_format = " % b %e % l: % M % p "
scl_events_url = " https://utilisocial.io/datacapable/v2/p/scl/map/events "
scl_events_response = requests . get ( scl_events_url )
try :
scl_events = scl_events_response . json ( )
except requests . JSONDecodeError :
print ( " JSON could not be loaded from SCL API " )
raise
2024-01-14 12:27:02 -08:00
config = yaml . safe_load ( open ( " config.yml " ) )
stadiamaps_api_key = config [ " stadiamaps " ] [ " api_key " ]
nominatim_url = config [ " nominatim " ] [ " api_base_url " ]
2024-01-31 21:34:58 -08:00
mastodon_client = Mastodon (
2024-01-14 12:27:02 -08:00
client_id = config [ " mastodon " ] [ " client_id " ] ,
client_secret = config [ " mastodon " ] [ " client_secret " ] ,
access_token = config [ " mastodon " ] [ " access_token " ] ,
api_base_url = config [ " mastodon " ] [ " api_base_url " ] ,
)
2024-01-13 17:13:38 -08:00
2024-04-20 12:08:39 -07:00
class AttribStaticMap ( staticmap . StaticMap , object ) :
2024-01-13 17:13:38 -08:00
def __init__ ( self , * args , * * kwargs ) :
self . attribution = " © Stadia Maps © OpenMapTiles © OpenStreetMap "
super ( AttribStaticMap , self ) . __init__ ( * args , * * kwargs )
def _draw_features ( self , image ) :
super ( AttribStaticMap , self ) . _draw_features ( image )
txt = Image . new ( " RGBA " , image . size , ( 255 , 255 , 255 , 0 ) )
2024-08-25 11:58:41 -07:00
fnt = ImageFont . truetype ( " fonts/PublicSans-Regular.otf " , 24 )
2024-01-13 17:13:38 -08:00
d = ImageDraw . Draw ( txt )
textSize = fnt . getbbox ( self . attribution )
textPosition = ( image . size [ 0 ] - textSize [ 2 ] , image . size [ 1 ] - textSize [ 3 ] )
offset = 2
options = { " fill " : ( 255 , 255 , 255 , 180 ) }
d . rectangle (
[
( textPosition [ 0 ] - ( 2 * offset ) , textPosition [ 1 ] - ( 2 * offset ) ) ,
(
textSize [ 2 ] + textPosition [ 0 ] + ( 2 * offset ) ,
textSize [ 3 ] + textPosition [ 1 ] + ( 2 * offset ) ,
) ,
] ,
* * options ,
)
# draw text, full opacity
d . text (
( textPosition [ 0 ] - offset , textPosition [ 1 ] - offset ) ,
self . attribution ,
font = fnt ,
fill = " black " ,
)
image . paste ( txt , ( 0 , 0 ) , txt )
2024-01-13 14:24:00 -08:00
2024-02-15 07:53:17 -08:00
def classify_event_size ( num_people : int ) - > dict [ str , str | bool ] :
2024-01-30 07:58:11 -08:00
if num_people < 250 :
return {
" size " : " Small " ,
" outage_color " : " #F97316 " ,
" is_postable " : False ,
}
2024-02-13 20:42:02 -08:00
elif num_people < 1000 :
2024-01-30 07:58:11 -08:00
return {
" size " : " Medium " ,
" outage_color " : " #EF4444 " ,
" is_postable " : True ,
}
else :
return {
" size " : " Large " ,
" outage_color " : " #991B1B " ,
" is_postable " : True ,
}
2024-02-15 07:53:17 -08:00
def get_hashtag_string ( event ) - > str :
2024-04-20 16:56:10 -07:00
city = str ( )
try :
city = event [ " geoloc_city " ]
except KeyError :
city = event [ " city " ]
neighborhood_text = str ( )
try :
neighborhood = event [ " neighborhood " ]
if neighborhood != city :
neighborhood_text = " # {} " . format ( neighborhood . title ( ) . replace ( " " , " " ) )
except KeyError :
pass
hashtag_string = " #SeattleCityLightOutage #SCLOutage {} # {} " . format (
neighborhood_text , city . title ( ) . replace ( " " , " " )
2024-02-15 07:53:17 -08:00
)
return hashtag_string
def do_initial_post (
2024-04-20 15:03:16 -07:00
event ,
event_class ,
start_time : datetime ,
estimated_restoration_time : datetime ,
outage_geometries : shapely . MultiPolygon ,
2024-02-15 07:53:17 -08:00
) - > dict [ str , str | None ] :
post_id = None
map_media_post_id = None
# Fallback location from the SCL API in case one couldn't be reverse geocoded
area_text = event [ " city " ]
try :
map = AttribStaticMap (
2024-08-25 11:58:41 -07:00
1024 ,
1024 ,
url_template = " https://tiles.stadiamaps.com/tiles/outdoors/ {z} / {x} / {y} @2x.png?api_key= "
2024-02-15 07:53:17 -08:00
+ stadiamaps_api_key ,
2024-08-25 11:58:41 -07:00
tile_size = 512 ,
2024-02-15 07:53:17 -08:00
)
assert event [ " polygons " ] [ " type " ] == " polygon "
for ring in event [ " polygons " ] [ " rings " ] :
2024-04-20 12:08:39 -07:00
polygon = staticmap . Polygon (
2024-02-15 07:53:17 -08:00
ring ,
# Appending 7F to the fill_color makes it 50% transparent
fill_color = " {} 7F " . format ( event_class [ " outage_color " ] ) ,
outline_color = event_class [ " outage_color " ] ,
simplify = True ,
)
map . add_polygon ( polygon )
try :
2024-04-20 15:03:16 -07:00
outage_center : shapely . Point = outage_geometries . centroid
2024-02-15 07:53:17 -08:00
2024-04-20 15:03:16 -07:00
assert outage_center . geom_type == " Point "
2024-02-15 07:53:17 -08:00
# Check to make sure the calculated lat and lon are sane enough
# NW Corner
2024-04-20 15:03:16 -07:00
assert outage_center . y < 48 and outage_center . x > - 122.6
2024-02-15 07:53:17 -08:00
# SE Corner
2024-04-20 15:03:16 -07:00
assert outage_center . y > 47.2 and outage_center . x < - 122
2024-02-15 07:53:17 -08:00
# Zoom level 17 ensures that we won't get any building/POI names, just street names
geocode_url = " {nominatim_url} /reverse?lat= {lat} &lon= {lon} &format=geocodejson&zoom=17 " . format (
nominatim_url = nominatim_url ,
2024-04-20 15:03:16 -07:00
lat = outage_center . y ,
lon = outage_center . x ,
2024-02-15 07:53:17 -08:00
)
geocode_headers = { " User-Agent " : " seattlecitylight-mastodon-bot " }
geocode_response = requests . get ( geocode_url , headers = geocode_headers )
try :
geocode = geocode_response . json ( )
except requests . JSONDecodeError :
print ( " JSON could not be loaded from nominatim API " )
raise
2024-04-20 17:18:37 -07:00
city = geocode [ " features " ] [ 0 ] [ " properties " ] [ " geocoding " ] [ " city " ]
street = geocode [ " features " ] [ 0 ] [ " properties " ] [ " geocoding " ] [ " name " ]
event [ " geoloc_city " ] = city
if city != " Seattle " :
city_not_seattle_text = " of {} " . format ( city )
2024-02-15 07:53:17 -08:00
else :
city_not_seattle_text = " "
if (
" locality " in geocode [ " features " ] [ 0 ] [ " properties " ] [ " geocoding " ]
and event_class [ " size " ] != " Large "
) :
locality = geocode [ " features " ] [ 0 ] [ " properties " ] [ " geocoding " ] [ " locality " ]
if locality == " Uptown " :
locality = " Lower Queen Anne "
alt_text = " A map showing the location of the outage, centered around {} in the {} area {} . " . format (
street ,
locality ,
city_not_seattle_text ,
)
area_text = " the {} area {} " . format ( locality , city_not_seattle_text )
2024-04-20 16:56:10 -07:00
event [ " neighborhood " ] = locality
2024-02-15 07:53:17 -08:00
elif " district " in geocode [ " features " ] [ 0 ] [ " properties " ] [ " geocoding " ] :
2024-04-20 16:56:10 -07:00
district = geocode [ " features " ] [ 0 ] [ " properties " ] [ " geocoding " ] [ " district " ]
2024-02-15 07:53:17 -08:00
alt_text = " A map showing the location of the outage, centered around {} in the {} area {} . " . format (
street ,
2024-04-20 16:56:10 -07:00
district ,
2024-02-15 07:53:17 -08:00
city_not_seattle_text ,
)
area_text = " the {} area {} " . format (
2024-04-20 16:56:10 -07:00
district ,
2024-02-15 07:53:17 -08:00
city_not_seattle_text ,
)
2024-04-20 16:56:10 -07:00
event [ " neighborhood " ] = district
2024-02-15 07:53:17 -08:00
else :
alt_text = " A map showing the location of the outage, centered around {} in {} . " . format (
street ,
2024-04-20 16:56:10 -07:00
city ,
2024-02-15 07:53:17 -08:00
)
2024-04-20 16:56:10 -07:00
area_text = city
2024-02-15 07:53:17 -08:00
except Exception :
alt_text = " A map showing the location of the outage. "
2024-04-20 15:03:16 -07:00
map_image = map . render ( )
2024-02-15 07:53:17 -08:00
with io . BytesIO ( ) as map_image_file :
2024-08-25 11:58:41 -07:00
map_image . save ( map_image_file , format = " WebP " , method = 6 )
2024-02-15 07:53:17 -08:00
map_media_post = mastodon_client . media_post (
map_image_file . getvalue ( ) ,
mime_type = " image/png " ,
description = alt_text ,
)
map_media_post_id = map_media_post [ " id " ]
except Exception as e :
print ( e )
print (
" Ran into an issue with generating/uploading the map. Will post without it. "
)
map_media_post = None
hashtag_string = get_hashtag_string ( event )
2024-06-02 18:30:15 -07:00
est_restoration_post_text = str ( )
if estimated_restoration_time > datetime . now ( ) :
est_restoration_post_text = " \n Est. Restoration: {} \n " . format (
estimated_restoration_time . strftime ( post_datetime_format )
)
2024-02-15 07:53:17 -08:00
post_text = """ Seattle City Light is reporting a {} outage in {} .
2024-06-02 18:30:15 -07:00
Start Date : { } { }
2024-02-15 07:53:17 -08:00
Cause : { }
{ } """ .format(
event_class [ " size " ] . lower ( ) ,
area_text ,
start_time . strftime ( post_datetime_format ) ,
2024-06-02 18:30:15 -07:00
est_restoration_post_text ,
2024-02-15 07:53:17 -08:00
event [ " cause " ] ,
hashtag_string ,
)
print (
" Posting the following to Mastodon, with a post length of {} : \n {} " . format (
len ( post_text ) , post_text
)
)
2024-11-23 17:07:02 -08:00
post_result = { " id " : " 0123 " }
# post_result = mastodon_client.status_post(
# status=post_text,
# media_ids=map_media_post,
# visibility="public",
# language="en",
# )
2024-02-15 07:53:17 -08:00
post_id = post_result [ " id " ]
return { " post_id " : post_id , " map_media_post_id " : map_media_post_id }
2024-01-13 14:24:00 -08:00
class Base ( DeclarativeBase ) :
2024-04-20 12:08:39 -07:00
type_annotation_map = {
2024-11-23 17:07:02 -08:00
Geometry : DBGeometry ,
2024-04-20 12:08:39 -07:00
}
2024-01-13 14:24:00 -08:00
class SclOutage ( Base ) :
__tablename__ = " scl_outages "
scl_outage_id : Mapped [ int ] = mapped_column ( primary_key = True , unique = True )
outage_user_id : Mapped [ str ] = mapped_column ( )
2024-01-13 15:00:05 -08:00
most_recent_post_id : Mapped [ Optional [ str ] ] = mapped_column ( )
2024-01-30 07:19:13 -08:00
initial_post_id : Mapped [ Optional [ str ] ] = mapped_column ( )
map_media_post_id : Mapped [ Optional [ str ] ] = mapped_column ( )
2024-01-13 14:24:00 -08:00
last_updated_time : Mapped [ datetime ] = mapped_column ( )
estimated_restoration_time : Mapped [ datetime ] = mapped_column ( )
cause : Mapped [ str ] = mapped_column ( )
status : Mapped [ Optional [ str ] ] = mapped_column ( )
2024-01-13 15:00:05 -08:00
no_longer_in_response_time : Mapped [ Optional [ datetime ] ] = mapped_column ( )
2024-01-29 19:42:12 -08:00
start_time : Mapped [ datetime ] = mapped_column ( )
2024-01-29 19:50:37 -08:00
num_people : Mapped [ int ] = mapped_column ( )
2024-01-30 07:58:11 -08:00
max_num_people : Mapped [ int ] = mapped_column ( )
2024-10-27 08:49:32 -07:00
neighborhood : Mapped [ Optional [ str ] ] = mapped_column ( )
city : Mapped [ Optional [ str ] ] = mapped_column ( )
2024-11-23 17:07:02 -08:00
outage_geometries : Mapped [ Optional [ Geometry ] ] = mapped_column ( )
geometries_modified : Mapped [ Optional [ bool ] ] = mapped_column ( )
2024-01-13 14:24:00 -08:00
def __repr__ ( self ) - > str :
2024-11-23 17:07:02 -08:00
return f " SclOutage(scl_outage_id= { self . scl_outage_id !r} , most_recent_post_id= { self . most_recent_post_id !r} , initial_post_id= { self . initial_post_id !r} , map_media_post_id= { self . map_media_post_id !r} , last_updated_time= { self . last_updated_time !r} , no_longer_in_response_time= { self . no_longer_in_response_time !r} , start_time= { self . start_time !r} , num_people= { self . num_people !r} , max_num_people= { self . max_num_people !r} , neighborhood= { self . neighborhood !r} , city= { self . city !r} , outage_geometries= { self . outage_geometries !r} , geometries_modified= { self . geometries_modified !r} ) "
2024-01-13 14:24:00 -08:00
2024-01-31 21:36:32 -08:00
engine = create_engine ( " sqlite:///scl.db " )
2024-01-13 14:24:00 -08:00
Base . metadata . create_all ( engine )
with Session ( engine ) as session :
for event in scl_events :
print ( " Processing outage with Internal ID {} " . format ( event [ " id " ] ) )
start_time = datetime . fromtimestamp ( event [ " startTime " ] / 1000 )
last_updated_time = datetime . fromtimestamp ( event [ " lastUpdatedTime " ] / 1000 )
estimated_restoration_time = datetime . fromtimestamp ( event [ " etrTime " ] / 1000 )
lookup_statement = select ( SclOutage ) . where (
SclOutage . scl_outage_id == event [ " id " ]
)
lookup_result = session . scalars ( lookup_statement )
2024-01-30 07:58:11 -08:00
event_class = classify_event_size ( event [ " numPeople " ] )
2024-01-13 14:24:00 -08:00
if " status " in event :
status = event [ " status " ]
else :
status = None
2024-04-20 12:08:39 -07:00
outage_geometries = convert_outage_geometry ( event )
2024-01-13 14:24:00 -08:00
try :
2024-02-15 07:53:17 -08:00
hashtag_string = get_hashtag_string ( event )
2024-01-13 14:24:00 -08:00
existing_record = lookup_result . one ( )
updated_properties = [ ]
updated_entries = [ ]
2024-10-27 10:54:56 -07:00
est_restoration_diff_mins = (
abs (
(
estimated_restoration_time
- existing_record . estimated_restoration_time
) . total_seconds ( )
)
/ 60
)
2024-10-27 12:30:44 -07:00
# Only post if estimated restoration time has changed by 60m or more
if est_restoration_diff_mins > = 60 :
2024-01-13 14:24:00 -08:00
existing_record . estimated_restoration_time = estimated_restoration_time
2024-06-02 18:30:15 -07:00
if estimated_restoration_time > datetime . now ( ) :
# New estimated restoration time is in the future, so likely to be a real time
updated_properties . append ( " estimated restoration " )
updated_entries . append (
" Est. Restoration: {} " . format (
estimated_restoration_time . strftime ( post_datetime_format )
)
2024-01-13 14:24:00 -08:00
)
if event [ " cause " ] != existing_record . cause :
existing_record . cause = event [ " cause " ]
updated_properties . append ( " cause " )
updated_entries . append ( " Cause: {} " . format ( event [ " cause " ] ) )
2024-01-30 07:58:11 -08:00
previous_event_class = classify_event_size ( existing_record . num_people )
if event_class [ " size " ] != previous_event_class [ " size " ] :
2024-01-13 14:24:00 -08:00
updated_properties . append ( " outage size " )
2024-01-30 07:58:11 -08:00
updated_entries . append ( " Outage Size: {} " . format ( event_class [ " size " ] ) )
2024-01-13 14:24:00 -08:00
if status != existing_record . status :
existing_record . status = status
updated_properties . append ( " status " )
updated_entries . append ( " Status: {} " . format ( status ) )
2024-01-29 19:50:37 -08:00
if existing_record . num_people != event [ " numPeople " ] :
existing_record . num_people = event [ " numPeople " ]
2024-01-30 07:58:11 -08:00
if existing_record . max_num_people < event [ " numPeople " ] :
# Used to determine the maximum number of people affected by this outage, to determine if it's worth posting about
existing_record . max_num_people = event [ " numPeople " ]
2024-01-30 17:57:25 -08:00
max_event_class = classify_event_size ( existing_record . max_num_people )
2024-04-20 12:08:39 -07:00
if existing_record . outage_geometries != outage_geometries :
2024-11-23 17:07:02 -08:00
print ( " Geometries modified " )
2024-04-20 12:08:39 -07:00
existing_record . outage_geometries = outage_geometries
2024-11-23 17:07:02 -08:00
existing_record . geometries_modified = True
2024-01-13 14:24:00 -08:00
if updated_properties :
updated_properties . sort ( )
updated_entries . sort ( )
if len ( updated_properties ) == 1 :
updated_entries . insert (
0 ,
" The {} of this outage has been updated. \n " . format (
updated_properties [ 0 ]
) ,
)
else :
# TODO: this currently just smashes all of the properties together with commas, it'd be nice to make it actually format it like a sentence
updated_entries . insert (
0 ,
" The {} of this outage have been updated. \n " . format (
" , " . join ( updated_properties )
) ,
)
2024-01-30 17:57:25 -08:00
if max_event_class [ " is_postable " ] and existing_record . initial_post_id :
2024-09-11 19:16:52 -07:00
try :
post_result = mastodon_client . status_post (
status = " \n " . join ( updated_entries ) ,
in_reply_to_id = existing_record . most_recent_post_id ,
visibility = " unlisted " ,
language = " en " ,
)
existing_record . most_recent_post_id = post_result [ " id " ]
except mastodon . MastodonNotFoundError :
2024-10-27 08:18:42 -07:00
print (
" Could not post a reply to the existing post, skip this update "
)
2024-01-30 17:57:25 -08:00
elif max_event_class [ " is_postable " ] :
print (
2024-02-15 07:53:17 -08:00
" Posting an event that grew above the threshold required to post "
2024-01-30 17:57:25 -08:00
)
2024-02-15 07:53:17 -08:00
initial_post_result = do_initial_post (
2024-04-20 15:03:16 -07:00
event ,
event_class ,
start_time ,
estimated_restoration_time ,
outage_geometries ,
2024-02-15 07:53:17 -08:00
)
2024-10-27 08:49:32 -07:00
try :
existing_record . neighborhood = initial_post_result [
" neighborhood "
]
except KeyError :
pass
try :
existing_record . city = initial_post_result [ " city " ]
except KeyError :
pass
2024-02-15 07:53:17 -08:00
existing_record . initial_post_id = initial_post_result [ " post_id " ]
existing_record . most_recent_post_id = initial_post_result [ " post_id " ]
existing_record . map_media_post_id = initial_post_result [
" map_media_post_id "
]
2024-04-20 12:08:39 -07:00
else :
print ( " Existing record was found, and no properties were updated. " )
2024-01-30 17:57:25 -08:00
session . commit ( )
2024-01-13 14:24:00 -08:00
except NoResultFound :
2024-01-29 19:42:12 -08:00
print ( " Existing record not found " )
2024-01-30 16:08:54 -08:00
post_id = None
map_media_post_id = None
2024-10-27 10:30:54 -07:00
neighborhood = None
city = None
2024-01-30 07:58:11 -08:00
if not event_class [ " is_postable " ] :
2024-01-30 16:00:00 -08:00
print (
" Outage is {} considered postable, will not post " . format (
event_class [ " size " ]
)
)
2024-01-29 19:42:12 -08:00
else :
2024-02-15 07:53:17 -08:00
initial_post_result = do_initial_post (
2024-04-20 15:03:16 -07:00
event ,
event_class ,
start_time ,
estimated_restoration_time ,
outage_geometries ,
2024-01-29 19:42:12 -08:00
)
2024-02-15 07:53:17 -08:00
post_id = initial_post_result [ " post_id " ]
map_media_post_id = initial_post_result [ " map_media_post_id " ]
2024-01-13 14:24:00 -08:00
2024-10-27 08:49:32 -07:00
try :
neighborhood = initial_post_result [ " neighborhood " ]
except KeyError :
pass
try :
city = initial_post_result [ " city " ]
except KeyError :
pass
2024-01-13 14:24:00 -08:00
new_outage_record = SclOutage (
scl_outage_id = event [ " id " ] ,
outage_user_id = event [ " identifier " ] ,
2024-01-30 16:08:54 -08:00
most_recent_post_id = post_id ,
initial_post_id = post_id ,
map_media_post_id = map_media_post_id ,
2024-01-13 14:24:00 -08:00
last_updated_time = last_updated_time ,
estimated_restoration_time = estimated_restoration_time ,
cause = event [ " cause " ] ,
status = status ,
2024-01-29 19:42:12 -08:00
start_time = start_time ,
2024-01-30 07:19:13 -08:00
num_people = event [ " numPeople " ] ,
2024-01-30 07:58:11 -08:00
max_num_people = event [ " numPeople " ] ,
2024-10-27 08:49:32 -07:00
neighborhood = neighborhood ,
city = city ,
2024-04-20 12:08:39 -07:00
outage_geometries = outage_geometries ,
2024-01-13 14:24:00 -08:00
)
session . add ( new_outage_record )
session . commit ( )
2024-01-31 21:30:44 -08:00
lookup_active_outages_statement = select ( SclOutage ) . where (
2024-02-10 20:51:41 -08:00
SclOutage . no_longer_in_response_time == None # noqa: E711 - Syntax must stay this way for SQLAlchemy
2024-01-13 14:24:00 -08:00
)
for active_outage in session . scalars ( lookup_active_outages_statement ) :
2024-01-31 21:26:41 -08:00
if (
not any ( event [ " id " ] == active_outage . scl_outage_id for event in scl_events )
2024-02-15 07:18:08 -08:00
and scl_events
2024-01-31 21:26:41 -08:00
) :
2024-01-13 14:24:00 -08:00
# Event ID no longer exists in response
2024-01-31 21:30:44 -08:00
if active_outage . most_recent_post_id :
2024-01-31 21:34:58 -08:00
try :
post_result = mastodon_client . status_post (
2024-10-27 08:18:42 -07:00
status = " This outage is no longer in the SCL feed, which usually means it ' s either been resolved, or split into multiple smaller outages. \n \n #SeattleCityLightOutage #SCLOutage " ,
2024-01-31 21:34:58 -08:00
in_reply_to_id = active_outage . most_recent_post_id ,
visibility = " public " ,
language = " en " ,
)
active_outage . most_recent_post_id = post_result [ " id " ]
except mastodon . MastodonNotFoundError :
print (
" The outage post couldn ' t be replied to, it was externally deleted. "
)
2024-01-13 14:24:00 -08:00
active_outage . no_longer_in_response_time = datetime . now ( )
session . commit ( )