diff --git a/RUNTIME_INSTRUCTIONS.md b/RUNTIME_INSTRUCTIONS.md new file mode 100644 index 0000000..7510ac4 --- /dev/null +++ b/RUNTIME_INSTRUCTIONS.md @@ -0,0 +1,50 @@ +# WeatherAlert - Integrating Alertus Solutions With Weather Data +[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black) + +## Description +WeatherAlert is a simple web application that utilizes the Alertus Solutions and forecast data based on geographical location. The website will display a table holding different forecasts and wether or not they have generated an alert. The alert is stored in a sqlite3 database with a unique ID. + +## Installation +##### Note: this project assumes a default python version of 3 or later. It has not been tested on any 2.x distribution. + +1. We're using a tool called [virtualenvwrapper](https://virtualenvwrapper.readthedocs.io/en/latest/install.html) to manage our work environment. To start it simply type ```workon weatheralert```. + +![Virtual environment setup](https://github.com/snaraj/weatheralert/blob/master/assets/images/virtual_env_setup.png?raw=true) + +2. After our virtual environment has been set up, we can install our project dependencies. Run ```pip3 install -r requirements.txt``` while on the root of the project. + +3. Since we are using Flask, we need to set up a few environment variables so we can operate out of the command line without issues. + +![Flask setup](https://github.com/snaraj/weatheralert/blob/master/assets/images/flask_setup.png?raw=true) + +## Usage + +1. To start our local server, we type ```flask run``` on the root folder. It should look like this. +![Flask run](https://github.com/snaraj/weatheralert/blob/master/assets/images/flask_run.png?raw=true) + +2. Once loaded, you will be greeted with two different components; a settings, and a data table component. +![Settings](https://github.com/snaraj/weatheralert/blob/master/assets/images/settings.png?raw=true) + +![Data](https://github.com/snaraj/weatheralert/blob/master/assets/images/data.png?raw=true) + +3. You can interact with the webpage by providing different (valid) coordinates as well as changing the threshold value. The Threshold Value is important since it determines wether the Alertus Solutions generates an alert or not. The screenshot below shows how it would look like after updating the website with a Threshold Value of 37. + +![Data Updated](https://github.com/snaraj/weatheralert/blob/master/assets/images/data_updated.png?raw=true) + +## Credits & Miscellanea + - Alertus: https://www.alertus.com/ + - NOAA: https://www.weather.gov/documentation/services-web-api + + - List of libraries: See requirements.txt + + - Challenges: + - Being able to update the data table dynamically based on the new settings passed by the user. + - Updating Jinja2 template dynamically using JavaScript. + - Moving data through different API's while conserving integrity. + - Authenticating our API usage. + + - Highlights: + - Was able to solve all of the problems above. + - I was able to figure out many things that I had never implemented before. + - App works as expected, described by the documentation posted. + - Overall this project was an amazing learning experience. \ No newline at end of file diff --git a/assets/.DS_Store b/assets/.DS_Store new file mode 100644 index 0000000..be640b5 Binary files /dev/null and b/assets/.DS_Store differ diff --git a/assets/images/.DS_Store b/assets/images/.DS_Store new file mode 100644 index 0000000..dfe8316 Binary files /dev/null and b/assets/images/.DS_Store differ diff --git a/assets/images/data.png b/assets/images/data.png new file mode 100644 index 0000000..c8732d6 Binary files /dev/null and b/assets/images/data.png differ diff --git a/assets/images/data_updated.png b/assets/images/data_updated.png new file mode 100644 index 0000000..eb7a84a Binary files /dev/null and b/assets/images/data_updated.png differ diff --git a/assets/images/flask_run.png b/assets/images/flask_run.png new file mode 100644 index 0000000..fb516f0 Binary files /dev/null and b/assets/images/flask_run.png differ diff --git a/assets/images/flask_setup.png b/assets/images/flask_setup.png new file mode 100644 index 0000000..a0802f2 Binary files /dev/null and b/assets/images/flask_setup.png differ diff --git a/assets/images/settings.png b/assets/images/settings.png new file mode 100644 index 0000000..77ed644 Binary files /dev/null and b/assets/images/settings.png differ diff --git a/assets/images/virtual_env_setup.png b/assets/images/virtual_env_setup.png new file mode 100644 index 0000000..79044aa Binary files /dev/null and b/assets/images/virtual_env_setup.png differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..a6ee48a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ['setuptools>=40.8.0', 'wheel'] +build-backend = 'setuptools.build_meta:__legacy__' + +[tools.pytest.ini_options] +addopts = '--cov=weatheralert' +testpaths = [ + 'test', +] + +[tool.mypy] +mypy_path = 'weatheralert' +check_untyped_defs = true +disallow_any_generics = true +ignore_missing_imports = true +no_implicit_optional = true +show_error_codes = true +strict_equality = true +warn_redundant_casts = true +warn_return_any = true +warn_unreachable = true +warn_unused_configs = true +no_implicit_reexport = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8781347 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,34 @@ +attrs==21.4.0 +certifi==2021.10.8 +charset-normalizer==2.0.10 +click==8.0.3 +distlib==0.3.4 +filelock==3.4.2 +flake8==4.0.1 +Flask==2.0.2 +idna==3.3 +iniconfig==1.1.1 +itsdangerous==2.0.1 +Jinja2==3.0.3 +MarkupSafe==2.0.1 +mccabe==0.6.1 +mypy==0.931 +mypy-extensions==0.4.3 +packaging==21.3 +platformdirs==2.4.1 +pluggy==1.0.0 +py==1.11.0 +pycodestyle==2.8.0 +pydantic==1.9.0 +pyflakes==2.4.0 +pyparsing==3.0.6 +pytest==6.2.5 +requests==2.27.1 +six==1.16.0 +toml==0.10.2 +tomli==2.0.0 +tox==3.24.5 +typing_extensions==4.0.1 +urllib3==1.26.8 +virtualenv==20.13.0 +Werkzeug==2.0.2 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4978703 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,37 @@ +[metadata] +name = weatheralert +author = Samuel Naranjo +description = Alertus Python Developer Candidate. +classifiers = + Programming Language :: Python :: 3 + Programming Language :: Python :: 3 :: Only + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + +project_urls = + Source = https://github.com/snaraj/weatheralert + +[options] +python_requires = >=3.8 +packages = find: +install_requires = + Flask >= 2.0.2 + Jinja2 >= 3.0.3 +zip_safe = no + +[options.extras_require] +testing = + pytest>=6.2.5 + pytest-cov>=2.0 + mypy>=0.931 + flak8>=4.0.1 + tox>=3.24.5 + +[options.package_data] +weatheralert = py.typed + +[flake8] +exclude = build,.git,.tox,./tests/.env +ignore = W504,W601 +max-line-length = 119 \ No newline at end of file diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7f1a176 --- /dev/null +++ b/setup.py @@ -0,0 +1,4 @@ +from setuptools import setup + +if __name__ == "__main__": + setup() diff --git a/weatheralert.db b/weatheralert.db new file mode 100644 index 0000000..f201f34 Binary files /dev/null and b/weatheralert.db differ diff --git a/weatheralert/.gitignore b/weatheralert/.gitignore new file mode 100644 index 0000000..a2a0f66 --- /dev/null +++ b/weatheralert/.gitignore @@ -0,0 +1,81 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Flask stuff: +instance/ +.webassets-cache + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mkdocs documentation +/site + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +node_modules +.DS_Store +.cache + +**/__pycache__ +.vscode/ \ No newline at end of file diff --git a/weatheralert/__init__.py b/weatheralert/__init__.py new file mode 100644 index 0000000..13b5e27 --- /dev/null +++ b/weatheralert/__init__.py @@ -0,0 +1,35 @@ +import os + +from flask import Flask + + +def create_app(test_config=None): + # creates the flask instance and configures the app + app = Flask("weatheralert", instance_relative_config=True) + # setting default configuration + app.config.from_mapping( + # to be made more secure doing deployment + SECRET_KEY="dev", + # location and name for the database + DATABASE=os.path.join(app.instance_path, "weatheralert.sqlite"), + ) + + if test_config is None: + # load our custom config file if it exists, store SECRET_KEY here + app.config.from_pyfile("config.py", silent=True) + + else: + # load test_config otherwise + app.config.from_mapping(test_config) + + # ensures the existence of the instance folder + try: + os.makedirs(app.instance_path) + except OSError: + pass + + from . import weatheralert + + app.register_blueprint(weatheralert.bp) + + return app diff --git a/weatheralert/alertus.py b/weatheralert/alertus.py new file mode 100644 index 0000000..4a6a7f4 --- /dev/null +++ b/weatheralert/alertus.py @@ -0,0 +1,32 @@ +import requests +import json + +from base64 import b64encode + + +url = "https://demo.alertustech.com/alertusmw/services/rest/activation/preset/" + + +# launches a Alertus preset alert with a given preset id, basic auth and a custom message +def activate_alert(alert_message: str): + res = requests.post( + url, + headers={"Content-Type": "application/json", "Authorization": authorization()}, + data=json.dumps( + { + "sender": "Dev Candidate - Samuel", + "presetId": 2207, + "text": alert_message, + } + ), + ) + + id = int(res.content.decode("utf-8")) + + return id + + +# basic authorization using username:password with encode and decode +def authorization() -> str: + auth_token = b64encode(("devcandidate:gooWmJQe").encode()).decode("ascii") + return f"Basic {auth_token}" diff --git a/weatheralert/db.py b/weatheralert/db.py new file mode 100644 index 0000000..844547e --- /dev/null +++ b/weatheralert/db.py @@ -0,0 +1,92 @@ +import sqlite3 +from typing import List, Any + + +class weatheralertDB: + + TABLE_NAME = "hourly_forecast" + DATABASE_NAME = "weatheralert.db" + LIMIT = 20 + + def __init__(self) -> None: + self.conn = None + + # allows to create a connection to db + def connect(self) -> None: + if self.conn is not None: + self.close() + + self.conn = sqlite3.connect(self.DATABASE_NAME, check_same_thread=False) + + # closes connection to db + def close(self) -> None: + self.conn.close() + self.conn = None + + def create_table(self): + # create a connection to the db + self.connect() + c = self.conn.cursor() + # create a table if it doesn't exist with the required columns + c.execute( + f""" CREATE TABLE IF NOT EXISTS {self.TABLE_NAME} ( + alert_generated INTEGER DEFAULT 0, + alert_id INTEGER DEFAULT 0, + first_forecast REAL, + longitude FLOAT, + latitude FLOAT, + second_forecast REAL, + third_forecast REAL, + timestamp REAL PRIMARY KEY + ) + """ + ) + + # inserts a new row to the hourly_forecast table + def insert_row(self, forecast_dict: dict) -> None: + self.connect() + c = self.conn.cursor() + c.execute( + f""" INSERT OR IGNORE INTO {self.TABLE_NAME} VALUES( + :alert_generated, + :alert_id, + :first_forecast, + :latitude, + :longitude, + :second_forecast, + :third_forecast, + :timestamp + )""", + forecast_dict, + ) + self.conn.commit() + self.close() + + # query db and return all forecast entries + def show_all(self) -> List[Any]: + # create a connection to the db + self.connect() + c = self.conn.cursor() + # execute SELECT command to get all entries from hourly_forecast table + c.execute( + f""" SELECT + timestamp, + longitude, + latitude, + first_forecast, + second_forecast, + third_forecast, + alert_generated, + alert_id + FROM {self.TABLE_NAME} + ORDER BY alert_id DESC + LIMIT {self.LIMIT} + """ + ) + # fetches all results + forecasts = c.fetchall() + # close the connection to the db + self.conn.commit() + self.close() + + return forecasts diff --git a/weatheralert/db_handler.py b/weatheralert/db_handler.py new file mode 100644 index 0000000..b0b4e98 --- /dev/null +++ b/weatheralert/db_handler.py @@ -0,0 +1,16 @@ +from typing import List +from weatheralert.db import weatheralertDB + +db = weatheralertDB() +db.create_table() + +# insert forecast to database +def populate_db(db: weatheralertDB, forecast_list: List[dict]) -> None: + size = range(len(forecast_list)) + for i in size: + db.insert_row(forecast_list[i]) + + +# returns the db +def fetch_db(): + return db.show_all() diff --git a/weatheralert/forecast.py b/weatheralert/forecast.py new file mode 100644 index 0000000..c7ccdf9 --- /dev/null +++ b/weatheralert/forecast.py @@ -0,0 +1,135 @@ +import requests +import re + +from typing import List +from weatheralert.alertus import activate_alert + +# read the settings.txt file and load the values +def load_settings(file: str) -> List[str]: + settings = [] + with open(file) as f: + for line in f.readlines(): + settings.append(line) + + return settings + + +# curate the settings and turn it into a dictionary for easy access +def curated_settings(settings: List[str]) -> dict[str, float]: + settings_dict = {} + # goes through every entry in settings, only grabbing the information that we care about + # while turning into a K,V pair such as {'latitude' : 39.23456} + for line in settings: + if "latitude" in line: + # re.search() grabs a string between two characters, we only care about values + # latitude, longitude, threshold and frequency + settings_dict["latitude"] = float(re.search(r"= (.*)\n", line).group(1)) + elif "longitude" in line: + settings_dict["longitude"] = float(re.search(r"= (.*)\n", line).group(1)) + elif "threshold_value" in line: + settings_dict["threshold_value"] = float( + re.search(r"= (.*)\n", line).group(1) + ) + elif "check_in_frequency" in line: + settings_dict["check_in_frequency"] = float( + re.search(r"= (.*)\n", line).group(1) + ) + else: + pass + + return settings_dict + + +# allows the template to update settings based on new user input +def get_settings_dict() -> dict[str, float]: + return curated_settings(load_settings("weatheralert/settings.txt")) + + +# we must generate the x, y and office coordinates with the given latitude and longitude +def generate_grid(latitude: float, longitude: float) -> dict[str, str]: + # params holds the values we need to generate a forecast + params = {} + + response = requests.get( + f"https://api.weather.gov/points/{latitude},{longitude}" + ).json() + + # store the params we need to make a forecast request + params["grid x"] = response["properties"]["gridX"] + params["grid y"] = response["properties"]["gridY"] + params["office"] = response["properties"]["gridId"] + + return params + + +# after generating the grid location, we can generate a forecast +def generate_forecast_list( + office: str, + grid_x: str, + grid_y: str, + longitude: float, + latitude: float, + threshold_value: int, +) -> List[dict]: + + forecast_list = [] + # full forecast retrieves over 100 entries + full_forecast = requests.get( + f"https://api.weather.gov/gridpoints/{office}/{grid_x},{grid_y}/forecast/hourly", + headers={"Accept": "application/cap+xml"}, + ).json() + + # constructs a list of forecast to be added to the database based on integer check + for i in range(0, 30, 3): + + first_forecast = full_forecast["properties"]["periods"][i]["temperature"] + second_forecast = full_forecast["properties"]["periods"][i + 1]["temperature"] + third_forecast = full_forecast["properties"]["periods"][i + 2]["temperature"] + + if ( + (first_forecast > threshold_value) + or (second_forecast > threshold_value) + or (third_forecast > threshold_value) + ): + alert_message = ( + "forecast value higher than threshold value found. initializing alert." + ) + forecast_list.append( + dict( + alert_generated=True, + alert_id=activate_alert(alert_message), + first_forecast=full_forecast["properties"]["periods"][i][ + "temperature" + ], + longitude=longitude, + latitude=latitude, + second_forecast=full_forecast["properties"]["periods"][i + 1][ + "temperature" + ], + third_forecast=full_forecast["properties"]["periods"][i + 2][ + "temperature" + ], + timestamp=full_forecast["properties"]["periods"][i]["startTime"], + ) + ) + else: + forecast_list.append( + dict( + alert_generated=False, + alert_id=0, + first_forecast=full_forecast["properties"]["periods"][i][ + "temperature" + ], + longitude=longitude, + latitude=latitude, + second_forecast=full_forecast["properties"]["periods"][i + 1][ + "temperature" + ], + third_forecast=full_forecast["properties"]["periods"][i + 2][ + "temperature" + ], + timestamp=full_forecast["properties"]["periods"][i]["startTime"], + ) + ) + + return forecast_list diff --git a/weatheralert/py.typed b/weatheralert/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/weatheralert/settings.txt b/weatheralert/settings.txt new file mode 100644 index 0000000..e7de5f6 --- /dev/null +++ b/weatheralert/settings.txt @@ -0,0 +1,7 @@ +# Location Information +latitude = 39.7456 +longitude = -97.0892 + +# Application Settings +threshold_value = 35 +check_in_frequency = 30 diff --git a/weatheralert/templates/base.html b/weatheralert/templates/base.html new file mode 100644 index 0000000..d789e00 --- /dev/null +++ b/weatheralert/templates/base.html @@ -0,0 +1,24 @@ + + +{% block head %} + + + + + + + + + {% block header_js %}{% endblock %} + {% block title %}{% endblock %} - WeatherAlert + +{% endblock %} + + + +
+ {% block body %} + {% endblock %} +
+ + \ No newline at end of file diff --git a/weatheralert/templates/home/home.html b/weatheralert/templates/home/home.html new file mode 100644 index 0000000..66303c6 --- /dev/null +++ b/weatheralert/templates/home/home.html @@ -0,0 +1,117 @@ +{% extends "base.html" %} +{% block title %} Home {% endblock %} +{% block header_js %} + +{% endblock %} + +{% block body %} + +
+

Configuration

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
+ +
+

Data

+ + + + + + + + + + + + + + + + + + {% for forecast in lst: %} + + {% if forecast['first_forecast'] > threshold_value or + forecast['second_forecast'] > threshold_value or + forecast['third_forecast'] > threshold_value %} + + + + + + + + + {% else %} + + + + + + + + + {% endif %} + + {% endfor %} + +
Alert IDAlert GeneratedTimeStampLongitudeLatitudeFirst ForecastSecond ForecastThird Forecast
{{ forecast['alert_id'] }}{{ forecast['alert_generated'] }}{{ forecast['timestamp'] }}{{ forecast['longitude'] }}{{ forecast['latitude'] }}{{ forecast['first_forecast'] }}{{ forecast['second_forecast'] }}{{ forecast['third_forecast'] }}{{ forecast['alert_id'] }}{{ forecast['alert_generated'] }}{{ forecast['timestamp'] }}{{ forecast['longitude'] }}{{ forecast['latitude'] }}{{ forecast['first_forecast'] }}{{ forecast['second_forecast'] }}{{ forecast['third_forecast'] }}
+
+{% endblock %} \ No newline at end of file diff --git a/weatheralert/templates/home/update_data.html b/weatheralert/templates/home/update_data.html new file mode 100644 index 0000000..67edded --- /dev/null +++ b/weatheralert/templates/home/update_data.html @@ -0,0 +1,14 @@ +
+

Data

+ + {% for forecast in lst: %} + + {% if forecast['alert_id'] > 0 %} + + {% else %} + + {% endif %} + + {% endfor %} +
{{ forecast }}{{ forecast }}
+
\ No newline at end of file diff --git a/weatheralert/weatheralert.db b/weatheralert/weatheralert.db new file mode 100644 index 0000000..bcb12b8 Binary files /dev/null and b/weatheralert/weatheralert.db differ diff --git a/weatheralert/weatheralert.py b/weatheralert/weatheralert.py new file mode 100644 index 0000000..08d1573 --- /dev/null +++ b/weatheralert/weatheralert.py @@ -0,0 +1,86 @@ +from flask import Blueprint, render_template, request, jsonify + +from weatheralert.db import weatheralertDB +from weatheralert.db_handler import populate_db, db +from weatheralert.forecast import ( + generate_grid, + get_settings_dict, + generate_forecast_list, +) + +bp = Blueprint("", __name__, url_prefix="") + +# settings dict that we can update throughout the project +settings = get_settings_dict() + +# setting up db +db = weatheralertDB() +db.create_table() + +# route that handles sending necessary json data to our javascript function to perform +# data updating +@bp.route("/_updateData", methods=["GET"]) +def updateData(): + grid = generate_grid(settings["latitude"], settings["longitude"]) + new_forecast_list = generate_forecast_list( + grid["office"], + grid["grid x"], + grid["grid y"], + settings["latitude"], + settings["longitude"], + settings["threshold_value"], + ) + + populate_db(db, new_forecast_list) + + return jsonify( + lst=new_forecast_list, + latitude=settings["latitude"], + longitude=settings["longitude"], + threshold_value=settings["threshold_value"], + ) + + +# display the data and highlight row that triggers warning +@bp.route("/", methods=["GET", "POST"]) +def populate_home(): + grid = generate_grid(settings["latitude"], settings["longitude"]) + + forecast_list = generate_forecast_list( + grid["office"], + grid["grid x"], + grid["grid y"], + settings["latitude"], + settings["longitude"], + settings["threshold_value"], + ) + populate_db(db, forecast_list) + + # trigger updating our settings when html form sends a POST request + if request.method == "POST": + if request.form["update_settings"] == "Update": + settings["latitude"] = float(request.form["latitude"]) + settings["longitude"] = float(request.form["longitude"]) + settings["threshold_value"] = int(request.form["threshold_value"]) + settings["check_in_frequency"] = int(request.form["check_in_frequency"]) + + grid = generate_grid(settings["latitude"], settings["longitude"]) + forecast_list = generate_forecast_list( + grid["office"], + grid["grid x"], + grid["grid y"], + settings["latitude"], + settings["longitude"], + settings["threshold_value"], + ) + + populate_db(db, forecast_list) + + return render_template( + "/home/home.html", + lst=forecast_list, + longitude=settings["longitude"], + latitude=settings["latitude"], + threshold_value=settings["threshold_value"], + check_in_frequency=settings["check_in_frequency"], + )