Skip to content

Commit

Permalink
Merge branch 'develop'
Browse files Browse the repository at this point in the history
  • Loading branch information
alexdlaird committed Feb 20, 2024
2 parents be88b6d + 1d193f5 commit 885ddb7
Show file tree
Hide file tree
Showing 8 changed files with 59 additions and 58 deletions.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,13 @@ nopyc:
clean: nopyc
rm -rf _build .venv

test: env virtualenv
test: install
@( \
source .venv/bin/activate; \
python -m coverage run -m unittest discover -v -b && python -m coverage xml -o _build/coverage/coverage.xml; \
)

run-devserver: env virtualenv
run-devserver: install
@( \
source .venv/bin/activate; \
FLASK_SKIP_DOTENV=1 FLASK_ENV=development FLASK_APP=devserver.py flask run; \
Expand Down
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
<p align="center"><img alt="Air Quality Bot - Text (415) 212-4229" src="https://www.alexlaird.com/content/uploads/2020/09/logo-2.png" /></p>

[![Build](https://github.com/alexdlaird/air-quality-bot/actions/workflows/build.yml/badge.svg)](https://github.com/alexdlaird/air-quality-bot/actions/workflows/build.yml)
[![Codecov](https://codecov.io/gh/alexdlaird/air-quality-bot/branch/main/graph/badge.svg)](https://codecov.io/gh/alexdlaird/air-quality-bot)
![Python Versions](https://img.shields.io/badge/python-%203.8%20|%203.9%20|%203.10%20|%203.11%20-blue)
![GitHub License](https://img.shields.io/github/license/alexdlaird/air-quality-bot)
[![Tweet](https://img.shields.io/twitter/url/http/shields.io.svg?style=social)](https://twitter.com/intent/tweet?text=Text+your+zip+code+to+the+%23AirQualityBot+at+%28415%29+212-4229%2C+and+optionally+add+%E2%80%9Cmap%E2%80%9D%2C+and+it%E2%80%99ll+respond+with+the+current+%23airquality+for+your+region.%0D%0A%0D%0AStay+safe+everyone.%0D%0A%0D%0A&url=https://github.com/alexdlaird/air-quality-bot&via=alexdlaird+@twilio&hashtags=Twilio,TwilioFunctions,AirNow,AQI)
[![Coverage](https://img.shields.io/codecov/c/github/alexdlaird/air-quality-bot)](https://codecov.io/gh/alexdlaird/air-quality-bot)
[![Build](https://img.shields.io/github/actions/workflow/status/alexdlaird/air-quality-bot/build.yml)](https://github.com/alexdlaird/air-quality-bot/actions/workflows/build.yml)
[![GitHub License](https://img.shields.io/github/license/alexdlaird/air-quality-bot)](https://github.com/alexdlaird/air-quality-bot/blob/main/LICENSE)

The Air Quality Bot is generally available by texting a zip code (and optionally
the word "map") to (415) 212-4229. The bot will respond with the latest air
Expand Down Expand Up @@ -154,6 +153,6 @@ can easily be redeployed at any time by rerunning the deploy script:

If you would like to get involved, be sure to review the [Contribution Guide](https://github.com/alexdlaird/air-quality-bot/blob/main/CONTRIBUTING.rst).

Want to contribute financially? If you've found `pyngrok` useful, [sponsorship](https://github.com/sponsors/alexdlaird) would
Want to contribute financially? If you've found Air Quality Bot useful, [sponsorship](https://github.com/sponsors/alexdlaird) would
also be greatly appreciated!

4 changes: 2 additions & 2 deletions devserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,15 +93,15 @@

# Open a ngrok tunnel to the dev server
public_url = ngrok.connect(port)
print(" * ngrok tunnel \"{}\" -> \"http://127.0.0.1:{}/\"".format(public_url, port))
print(f" * ngrok tunnel \"{public_url}\" -> \"http://127.0.0.1:{port}/\"")

TWILIO_ACCOUNT_SID = os.environ.get("AIR_QUALITY_DEV_TWILIO_ACCOUNT_SID", None)
TWILIO_AUTH_TOKEN = os.environ.get("AIR_QUALITY_DEV_TWILIO_AUTH_TOKEN", None)
TWILIO_SMS_NUMBER = os.environ.get("AIR_QUALITY_DEV_TWILIO_SMS_NUMBER", None)

# Update any base URLs or webhooks to use the public ngrok URL
if TWILIO_ACCOUNT_SID and TWILIO_AUTH_TOKEN and TWILIO_SMS_NUMBER:
callback_url = "{}/inbound".format(public_url)
callback_url = f"{public_url}/inbound"

client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
phone_number_sid = client.incoming_phone_numbers.list(phone_number=TWILIO_SMS_NUMBER)[0].sid
Expand Down
50 changes: 28 additions & 22 deletions lambdas/aqi_GET/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@

AIRNOW_API_KEYS = json.loads(os.environ.get("AIRNOW_API_KEYS"))
AIRNOW_API_URL = os.environ.get("AIRNOW_API_URL",
"http://www.airnowapi.org/aq/observation/zipCode/current/?format=application/json&zipCode={}&distance=75&API_KEY={}")
AIRNOW_URL = os.environ.get("AIRNOW_URL", "https://airnow.gov/index.cfm?action=airnow.local_city&zipcode={}&submit=Go")
"http://www.airnowapi.org/aq/observation/zipCode/current/?format=application/json&zipCode={zip_code}&distance=75&API_KEY={api_key}")
AIRNOW_URL = os.environ.get("AIRNOW_URL",
"https://airnow.gov/index.cfm?action=airnow.local_city&zipcode={zip_code}&submit=Go")
AIRNOW_MAP_URL_PREFIX = os.environ.get("AIRNOW_MAP_URL_PREFIX", "https://files.airnowtech.org/airnow/today/")

_AIRNOW_API_TIMEOUT = 2
Expand All @@ -42,10 +43,10 @@

@conditional_decorator(datadog_lambda_wrapper, not os.environ.get("FLASK_APP", None))
def lambda_handler(event, context):
logger.info("Event: {}".format(event))
logger.info(f"Event: {event}")

query_string = event["params"]["querystring"]
logger.info("Query String: {}".format(query_string))
logger.info(f"Query String: {query_string}")

metricutils.increment("aqi_GET.request")

Expand Down Expand Up @@ -114,10 +115,10 @@ def lambda_handler(event, context):
def _get_zip_code_data(zip_code, utc_dt):
db_zip_read = table.get_item(
Key={
"PartitionKey": "ZipCode:{}".format(zip_code)
"PartitionKey": f"ZipCode:{zip_code}"
}
)
logger.info("DynamoDB ZipCode read response: {}".format(db_zip_read))
logger.info(f"DynamoDB ZipCode read response: {db_zip_read}")

data = None
if "Item" not in db_zip_read or (utc_dt - parser.parse(db_zip_read["Item"]["LastUpdated"])).total_seconds() > 3600:
Expand Down Expand Up @@ -145,12 +146,14 @@ def _get_zip_code_data(zip_code, utc_dt):
def _airnow_api_request(zip_code, utc_dt, data, retries=0):
airnow_api_key = random.choice(AIRNOW_API_KEYS)

logger.info("AirNow API URL: {}".format(AIRNOW_API_URL.format(zip_code, airnow_api_key)))
url = AIRNOW_API_URL.format(zip_code=zip_code, api_key=airnow_api_key)
logger.info(f"AirNow API URL: {url}")

try:
response = requests.get(AIRNOW_API_URL.format(zip_code, airnow_api_key), timeout=_AIRNOW_API_TIMEOUT)
response = requests.get(AIRNOW_API_URL.format(zip_code=zip_code, api_key=airnow_api_key),
timeout=_AIRNOW_API_TIMEOUT)

logger.info("AirNow API response: {}".format(response.text))
logger.info(f"AirNow API response: {response.text}")

response_json = response.json()

Expand All @@ -169,14 +172,14 @@ def _airnow_api_request(zip_code, utc_dt, data, retries=0):
data[parameter["ParameterName"]] = parameter

if "PM2.5" in data or "PM10" in data or "O3" in data:
data["PartitionKey"] = "ZipCode:{}".format(zip_code)
data["PartitionKey"] = f"ZipCode:{zip_code}"
data["LastUpdated"] = utc_dt.isoformat()
data["TTL"] = int((utc_dt + timedelta(hours=24) - datetime.fromtimestamp(0)).total_seconds())

db_zip_write = table.put_item(
Item=data
)
logger.info("DynamoDB ZipCode write response: {}".format(db_zip_write))
logger.info(f"DynamoDB ZipCode write response: {db_zip_write}")
else:
logger.info("AirNow data is unavailable for this zip code, not caching")
except requests.exceptions.RequestException as e:
Expand Down Expand Up @@ -206,11 +209,12 @@ def _airnow_api_request(zip_code, utc_dt, data, retries=0):
def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt):
db_reporting_area_read = table.get_item(
Key={
"PartitionKey": "ReportingArea:{}|{}".format(zip_code_data[parameter_name]["ReportingArea"],
zip_code_data[parameter_name]["StateCode"])
"PartitionKey": "ReportingArea:{reporting_area}|{status_code}".format(
reporting_area=zip_code_data[parameter_name]["ReportingArea"],
status_code=zip_code_data[parameter_name]["StateCode"])
}
)
logger.info("DynamoDB ReportingArea read response: {}".format(db_reporting_area_read))
logger.info(f"DynamoDB ReportingArea read response: {db_reporting_area_read}")

data = None
if "Item" not in db_reporting_area_read or (
Expand All @@ -226,8 +230,9 @@ def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt):

db_reporting_area_update = table.update_item(
Key={
"PartitionKey": "ReportingArea:{}|{}".format(zip_code_data[parameter_name]["ReportingArea"],
zip_code_data[parameter_name]["StateCode"])
"PartitionKey": "ReportingArea:{reporting_area}|{status_code}".format(
reporting_area=zip_code_data[parameter_name]["ReportingArea"],
status_code=zip_code_data[parameter_name]["StateCode"])
},
UpdateExpression="set LastUpdated = :dt, CachedAQI = :aqi",
ExpressionAttributeValues={
Expand All @@ -236,13 +241,13 @@ def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt):
},
ReturnValues="UPDATED_NEW"
)
logger.info("DynamoDB ReportingArea update response: {}".format(db_reporting_area_update))
logger.info(f"DynamoDB ReportingArea update response: {db_reporting_area_update}")
elif "Item" not in db_reporting_area_read:
logger.info("No ReportingArea value found, querying AirNow for data")

try:
metricutils.increment("aqi_GET.airnow-request")
response = requests.get(AIRNOW_URL.format(zip_code_data["PartitionKey"][len("ZipCode") + 1:]),
response = requests.get(AIRNOW_URL.format(zip_code=zip_code_data["PartitionKey"][len("ZipCode") + 1:]),
timeout=_AIRNOW_TIMEOUT)

if AIRNOW_MAP_URL_PREFIX in response.text:
Expand All @@ -252,15 +257,16 @@ def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt):
data = {
"MapUrl": map_url,
"CachedAQI": zip_code_data.copy(),
"PartitionKey": "ReportingArea:{}|{}".format(zip_code_data[parameter_name]["ReportingArea"],
zip_code_data[parameter_name]["StateCode"]),
"PartitionKey": "ReportingArea:{reporting_area}|{status_code}".format(
reporting_area=zip_code_data[parameter_name]["ReportingArea"],
status_code=zip_code_data[parameter_name]["StateCode"]),
"LastUpdated": utc_dt.isoformat()
}

db_reporting_area_write = table.put_item(
Item=data
)
logger.info("DynamoDB ReportingArea write response: {}".format(db_reporting_area_write))
logger.info(f"DynamoDB ReportingArea write response: {db_reporting_area_write}")
except requests.exceptions.ConnectionError as e:
# We don't retry these as they're expensive and infrequent, and
# once we have the URL for the ReportingArea map, it doesn't expire
Expand All @@ -273,6 +279,6 @@ def _get_reporting_area_data(zip_code_data, parameter_name, utc_dt):

data = db_reporting_area_read["Item"]

logger.info("Response data: {}".format(data))
logger.info(f"Response data: {data}")

return data
35 changes: 16 additions & 19 deletions lambdas/inbound_POST/lambda_function.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,18 +31,18 @@

@conditional_decorator(datadog_lambda_wrapper, not os.environ.get("FLASK_APP", None))
def lambda_handler(event, context):
logger.info("Event: {}".format(event))
logger.info(f"Event: {event}")

query_string = event["params"]["querystring"]
logger.info("Query String: {}".format(query_string))
logger.info(f"Query String: {query_string}")

metricutils.increment("inbound_POST.request")

data = parse.parse_qs(event["body-json"])
phone_number = data["From"][0]
body = data["Body"][0]

logger.info("Received \"{}\" from {}".format(body, phone_number))
logger.info(f"Received \"{body}\" from {phone_number}")

zip_code = body.lower().strip()
include_map = "map" in zip_code
Expand All @@ -61,8 +61,8 @@ def lambda_handler(event, context):
zip_code = zip_code.split("map")[0].strip()

try:
response = requests.get(
"{}/aqi?zipCode={}".format(AIR_QUALITY_API_URL, zip_code, timeout=_AIR_QUALITY_API_TIMEOUT)).json()
response = requests.get(f"{AIR_QUALITY_API_URL}/aqi?zipCode={zip_code}",
timeout=_AIR_QUALITY_API_TIMEOUT).json()
except requests.exceptions.RequestException as e:
metricutils.increment("inbound_POST.error.aqi-request-failed")
logger.error(e)
Expand All @@ -71,7 +71,7 @@ def lambda_handler(event, context):
"errorMessage": "Oops, an unknown error occurred. AirNow may be overloaded at the moment."
}

logger.info("Response from `/aqi`: {}".format(response))
logger.info(f"Response from `/aqi`: {response}")

if "errorMessage" in response:
return _get_response(response["errorMessage"])
Expand All @@ -95,19 +95,17 @@ def lambda_handler(event, context):
response[parameter_name]["HourObserved"]
time = str(int(12 if time == "00" else time)) + suffix + " " + response[parameter_name]["LocalTimeZone"]

msg = "{} AQI of {} {} for {} at {}. {}\nSource: AirNow".format(
response[parameter_name]["Category"]["Name"],
int(response[parameter_name]["AQI"]),
parameter_name,
response[parameter_name]["ReportingArea"], time,
_AQI_MESSAGES[
response[parameter_name]["Category"][
"Name"]])
msg = "{category_name} AQI of {aqi} {param_name} for {reporting_area} at {time}. {category_name}\nSource: AirNow".format(
category_name=response[parameter_name]["Category"]["Name"],
aqi=int(response[parameter_name]["AQI"]),
param_name=parameter_name,
reporting_area=response[parameter_name]["ReportingArea"],
time=time)

media = None
if include_map:
# if "MapUrl" in response[parameter_name]:
media = "https://gispub.epa.gov/airnow/images/current-pm-ozone.jpg" #response[parameter_name]["MapUrl"]
media = "https://gispub.epa.gov/airnow/images/current-pm-ozone.jpg" # response[parameter_name]["MapUrl"]
# else:
# metricutils.increment("inbound_POST.warn.map-request-failed")
# logger.info("Map requested but not included, no MapUrl provided from AirNow")
Expand All @@ -118,10 +116,9 @@ def lambda_handler(event, context):
def _get_response(msg, media=None):
media_block = ""
if media is not None:
media_block = "<Media>{}</Media>".format(media)
media_block = f"<Media>{media}</Media>"

xml_response = "<?xml version='1.0' encoding='UTF-8'?><Response><Message><Body>{}</Body>{}</Message></Response>".format(
msg, media_block)
logger.info("XML response: {}".format(xml_response))
xml_response = f"<?xml version='1.0' encoding='UTF-8'?><Response><Message><Body>{msg}</Body>{media_block}</Message></Response>"
logger.info(f"XML response: {xml_response}")

return {"body": xml_response}
8 changes: 4 additions & 4 deletions tests/test_inbound.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def test_inbound_94501(self):
self.given_api_routes_mocked()
self.given_airnow_routes_mocked()

event = self.load_resource("inbound_{}.json".format(zip_code))
event = self.load_resource(f"inbound_{zip_code}.json")

response = lambda_function.lambda_handler(event, {})

Expand All @@ -37,7 +37,7 @@ def test_inbound_94501_map(self):
self.given_api_routes_mocked()
self.given_airnow_routes_mocked()

event = self.load_resource("inbound_{}_map.json".format(zip_code))
event = self.load_resource(f"inbound_{zip_code}_map.json")

response = lambda_function.lambda_handler(event, {})

Expand All @@ -62,7 +62,7 @@ def test_inbound_52328(self):
self.given_api_routes_mocked()
self.given_airnow_routes_mocked()

event = self.load_resource("inbound_{}.json".format(zip_code))
event = self.load_resource(f"inbound_{zip_code}.json")

response = lambda_function.lambda_handler(event, {})

Expand All @@ -75,7 +75,7 @@ def test_error_message_response(self):
zip_code = "94501"
self.given_dynamo_table_exists()

event = self.load_resource("inbound_{}.json".format(zip_code))
event = self.load_resource(f"inbound_{zip_code}.json")

response = lambda_function.lambda_handler(event, {})

Expand Down
5 changes: 2 additions & 3 deletions tests/testcase.py
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ def _aqi_request_callback(request):
return (200, {}, json.dumps(aqi_route.lambda_handler(event, {}), default=decimal_default))

responses.add_callback(
responses.GET, "{}/aqi".format(os.environ.get("AIR_QUALITY_API_URL").lower()),
responses.GET, f"{os.environ.get('AIR_QUALITY_API_URL').lower()}/aqi",
callback=_aqi_request_callback
)

Expand All @@ -136,8 +136,7 @@ def _airnow_request_callback(request):
map_url = {
"94501": "https://files.airnowtech.org/airnow/today/cur_aqi_sanfrancisco_ca.jpg"
}[zip_code]
data = "<html><img src=\"{}\" width=\"525\" height=\"400\" border=\"0\" style=\"position:relative\" usemap=\"#CurMap\"/></html>".format(
map_url)
data = f"<html><img src=\"{map_url}\" width=\"525\" height=\"400\" border=\"0\" style=\"position:relative\" usemap=\"#CurMap\"/></html>"

return (200, {}, data)

Expand Down
2 changes: 1 addition & 1 deletion utils/metricutils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,4 @@


def increment(metric):
lambda_metric("airqualitybot.{}".format(metric), 1)
lambda_metric(f"airqualitybot.{metric}", 1)

0 comments on commit 885ddb7

Please sign in to comment.