diff --git a/alerts_backend/python/app.py b/alerts_backend/python/app.py index 92269b5..24cc843 100644 --- a/alerts_backend/python/app.py +++ b/alerts_backend/python/app.py @@ -1,16 +1,32 @@ """Query alert information from AeroAPI and present it to a frontend service""" import os from datetime import datetime -from typing import Dict, Any, Tuple +from typing import Dict, Any, Tuple, Set +import copy import json import requests from flask import Flask, jsonify, Response, request +from flask.logging import create_logger from flask_cors import CORS -from sqlalchemy import (exc, create_engine, MetaData, Table, - Column, Integer, Boolean, Text, insert, Date, DateTime) from sqlalchemy.sql import func +from sqlalchemy import ( + exc, + create_engine, + MetaData, + Table, + Column, + Integer, + Boolean, + Text, + insert, + Date, + select, + DateTime, + delete, + update, +) AEROAPI_BASE_URL = "https://aeroapi.flightaware.com/aeroapi" AEROAPI_KEY = os.environ["AEROAPI_KEY"] @@ -19,6 +35,7 @@ # pylint: disable=invalid-name app = Flask(__name__) +logger = create_logger(app) CORS(app) # create the SQL engine using SQLite @@ -34,41 +51,42 @@ metadata_obj = MetaData() # Table for alert configurations aeroapi_alert_configurations = Table( - "aeroapi_alert_configurations", - metadata_obj, - Column("fa_alert_id", Integer, primary_key=True), - Column("ident", Text), - Column("origin", Text), - Column("destination", Text), - Column("aircraft_type", Text), - Column("start_date", Date), - Column("end_date", Date), - Column("max_weekly", Integer), - Column("eta", Integer), - Column("arrival", Boolean), - Column("cancelled", Boolean), - Column("departure", Boolean), - Column("diverted", Boolean), - Column("filed", Boolean), - ) + "aeroapi_alert_configurations", + metadata_obj, + Column("fa_alert_id", Integer, primary_key=True), + Column("ident", Text), + Column("origin", Text), + Column("destination", Text), + Column("aircraft_type", Text), + Column("start_date", Date), + Column("end_date", Date), + Column("max_weekly", Integer), + Column("eta", Integer), + Column("arrival", Boolean), + Column("cancelled", Boolean), + Column("departure", Boolean), + Column("diverted", Boolean), + Column("filed", Boolean), +) # Table for POSTed alerts aeroapi_alerts = Table( - "aeroapi_alerts", - metadata_obj, - Column("id", Integer, primary_key=True, autoincrement=True), - Column("time_alert_received", DateTime(timezone=True), server_default=func.now()), # Store time in UTC that the alert was received - Column("long_description", Text), - Column("short_description", Text), - Column("summary", Text), - Column("event_code", Text), - Column("alert_id", Integer), - Column("fa_flight_id", Text), - Column("ident", Text), - Column("registration", Text), - Column("aircraft_type", Text), - Column("origin", Text), - Column("destination", Text) - ) + "aeroapi_alerts", + metadata_obj, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("time_alert_received", DateTime(timezone=True), server_default=func.now()), + # Store time in UTC that the alert was received + Column("long_description", Text), + Column("short_description", Text), + Column("summary", Text), + Column("event_code", Text), + Column("alert_id", Integer), + Column("fa_flight_id", Text), + Column("ident", Text), + Column("registration", Text), + Column("aircraft_type", Text), + Column("origin", Text), + Column("destination", Text), +) def create_tables(): @@ -79,10 +97,12 @@ def create_tables(): try: # Create the table(s) if they don't exist metadata_obj.create_all(engine) - app.logger.info("Table(s) successfully created (if not already created)") + logger.info("Table(s) successfully created (if not already created)") except exc.SQLAlchemyError as e: # Since creation of table(s) is a critical error, raise exception - app.logger.error(f"SQL error occurred during creation of table(s) (CRITICAL - THROWING ERROR): {e}") + logger.error( + f"SQL error occurred during creation of table(s) (CRITICAL - THROWING ERROR): {e}" + ) raise e @@ -99,13 +119,250 @@ def insert_into_table(data_to_insert: Dict[str, Any], table: Table) -> int: stmt = insert(table) conn.execute(stmt, data_to_insert) conn.commit() - app.logger.info(f"Data successfully inserted into table {table.name}") + logger.info(f"Data successfully inserted into table {table.name}") + except exc.SQLAlchemyError as e: + logger.error(f"SQL error occurred during insertion into table {table.name}: {e}") + return -1 + return 0 + + +def delete_from_table(fa_alert_id: int): + """ + Delete alert config from SQL Alert Configurations table based on FA Alert ID. + Returns 0 on success, -1 otherwise. + """ + try: + with engine.connect() as conn: + stmt = delete(aeroapi_alert_configurations).where( + aeroapi_alert_configurations.c.fa_alert_id == fa_alert_id + ) + conn.execute(stmt) + conn.commit() + logger.info(f"Data successfully deleted from {aeroapi_alert_configurations.name}") + except exc.SQLAlchemyError as e: + logger.error( + f"SQL error occurred during deletion from table {aeroapi_alert_configurations.name}: {e}" + ) + return -1 + return 0 + + +def modify_from_table(fa_alert_id: int, modified_data: Dict[str, Any]): + """ + Updates alert config from SQL Alert Configurations table based on FA Alert ID. + Returns 0 on success, -1 otherwise. + """ + try: + with engine.connect() as conn: + stmt = update(aeroapi_alert_configurations).where( + aeroapi_alert_configurations.c.fa_alert_id == fa_alert_id + ) + conn.execute(stmt, modified_data) + conn.commit() + logger.info(f"Data successfully updated in table {aeroapi_alert_configurations.name}") except exc.SQLAlchemyError as e: - app.logger.error(f"SQL error occurred during insertion into table {table.name}: {e}") + logger.error( + f"SQL error occurred during updating in table {aeroapi_alert_configurations.name}: {e}" + ) return -1 return 0 +def get_alerts_not_from_app(existing_alert_ids: Set[int]): + """ + Function to get all alert configurations that were not configured + inside the webapp. Follows exact same format as SQL table, with extra + "is_from_app" column set to False. Takes in existing_alerts parameter + as a list to compare with configured alerts to ensure no overlap. + Returns a dictionary of all the alerts. If no alerts exist, return None. + """ + api_resource = "/alerts" + logger.info(f"Making AeroAPI request to GET {api_resource}") + result = AEROAPI.get(f"{AEROAPI_BASE_URL}{api_resource}") + if not result: + return None + all_alerts = result.json()["alerts"] + if not all_alerts: + return None + alerts_not_from_app = [] + for alert in all_alerts: + if int(alert["id"]) not in existing_alert_ids: + # Don't have to catch key doesn't exist as AeroAPI guarantees + # Keys will exist (just might be null) + holder = { + "fa_alert_id": alert["id"], + "ident": alert["ident"], + "origin": alert["origin"], + "destination": alert["destination"], + "aircraft_type": alert["aircraft_type"], + "start_date": alert["start"], + "end_date": alert["end"], + "max_weekly": 1000, + "eta": alert["eta"], + "arrival": alert["events"]["arrival"], + "cancelled": alert["events"]["cancelled"], + "departure": alert["events"]["departure"], + "diverted": alert["events"]["diverted"], + "filed": alert["events"]["filed"], + "is_from_app": False, + } + alerts_not_from_app.append(holder) + return alerts_not_from_app + + +@app.route("/modify", methods=["POST"]) +def modify_alert(): + """ + Function to modify the alert given (with key "fa_alert_id" in the payload). + Modifies the given alert via AeroAPI PUT call and also modifies the respective + alert in the SQLite database. Returns JSON Response in form {"Success": True/False, + "Description": } + """ + r_success: bool = False + r_description: str + # Process json + content_type = request.headers.get("Content-Type") + data: Dict[str, Any] + + if content_type != "application/json": + r_description = "Invalid content sent" + else: + data = request.json + + fa_alert_id = data.pop("fa_alert_id") + + # Make deep copy to send to AeroAPI - needs events in nested dictionary + aeroapi_adjusted_data = copy.deepcopy(data) + aeroapi_adjusted_data["events"] = { + "arrival": aeroapi_adjusted_data.pop("arrival"), + "departure": aeroapi_adjusted_data.pop("departure"), + "cancelled": aeroapi_adjusted_data.pop("cancelled"), + "diverted": aeroapi_adjusted_data.pop("diverted"), + "filed": aeroapi_adjusted_data.pop("filed"), + } + # Rename start and end again + aeroapi_adjusted_data["start"] = aeroapi_adjusted_data.pop("start_date") + aeroapi_adjusted_data["end"] = aeroapi_adjusted_data.pop("end_date") + + api_resource = f"/alerts/{fa_alert_id}" + logger.info(f"Making AeroAPI request to PUT {api_resource}") + result = AEROAPI.put(f"{AEROAPI_BASE_URL}{api_resource}", json=aeroapi_adjusted_data) + if result.status_code != 204: + # return to front end the error, decode and clean the response + try: + processed_json = result.json() + r_description = f"Error code {result.status_code} with the following description for alert configuration {fa_alert_id}: {processed_json['detail']}" + except json.decoder.JSONDecodeError: + r_description = f"Error code {result.status_code} for the alert configuration {fa_alert_id} could not be parsed into JSON. The following is the HTML response given: {result.text}" + else: + # Parse into datetime to update in SQLite table + if data["start_date"]: + data["start_date"] = datetime.strptime(data["start_date"], "%Y-%m-%d") + if data["end_date"]: + data["end_date"] = datetime.strptime(data["end_date"], "%Y-%m-%d") + + # Check if data was inserted into database properly + if modify_from_table(fa_alert_id, data) == -1: + r_description = ( + "Error modifying the alert configuration from the SQL Database - since it was modified " + "on AeroAPI but not SQL, this means the alert will still be the original alert on the table - in " + "order to properly modify the alert please look in your SQL database." + ) + else: + r_success = True + r_description = ( + f"Request sent successfully, alert configuration {fa_alert_id} has been updated" + ) + + return jsonify({"Success": r_success, "Description": r_description}) + + +@app.route("/delete", methods=["POST"]) +def delete_alert(): + """ + Function to delete the alert given (with key "fa_alert_id" in the payload). + Deletes the given alert via AeroAPI DELETE call and then deletes it from the + SQLite database. Returns JSON Response in form {"Success": True/False, + "Description": } + """ + r_success: bool = False + r_description: str + # Process json + content_type = request.headers.get("Content-Type") + data: Dict[str, Any] + + if content_type != "application/json": + r_description = "Invalid content sent" + else: + data = request.json + fa_alert_id = data["fa_alert_id"] + api_resource = f"/alerts/{fa_alert_id}" + logger.info(f"Making AeroAPI request to DELETE {api_resource}") + result = AEROAPI.delete(f"{AEROAPI_BASE_URL}{api_resource}", json=data) + if result.status_code != 204: + # return to front end the error, decode and clean the response + try: + processed_json = result.json() + r_description = f"Error code {result.status_code} with the following description for alert configuration {fa_alert_id}: {processed_json['detail']}" + except json.decoder.JSONDecodeError: + r_description = f"Error code {result.status_code} for the alert configuration {fa_alert_id} could not be parsed into JSON. The following is the HTML response given: {result.text}" + else: + # Check if data was inserted into database properly + if delete_from_table(fa_alert_id) == -1: + r_description = "Error deleting the alert configuration from the SQL Database - since it was deleted \ + on AeroAPI but not SQL, this means the alert will still be shown on the table - in order to properly \ + delete the alert please look in your SQL database." + else: + r_success = True + r_description = ( + f"Request sent successfully, alert configuration {fa_alert_id} has been deleted" + ) + + return jsonify({"Success": r_success, "Description": r_description}) + + +@app.route("/posted_alerts") +def get_posted_alerts(): + """ + Function to return all the alerts that are currently configured + via the SQL table. + """ + data: Dict[str, Any] = {"posted_alerts": []} + with engine.connect() as conn: + stmt = select(aeroapi_alerts) + result = conn.execute(stmt) + conn.commit() + for row in result: + data["posted_alerts"].append(dict(row)) + + return jsonify(data) + + +@app.route("/alert_configs") +def get_alert_configs(): + """ + Function to return all the alerts that are currently configured + via the SQL table. + """ + data: Dict[str, Any] = {"alert_configurations": []} + existing_alert_ids = set() + with engine.connect() as conn: + stmt = select(aeroapi_alert_configurations) + result = conn.execute(stmt) + conn.commit() + for row in result: + row_holder = dict(row) + row_holder["is_from_app"] = True + data["alert_configurations"].append(row_holder) + existing_alert_ids.add(row_holder["fa_alert_id"]) + + # Append alerts not created from app + alerts_not_from_app = get_alerts_not_from_app(existing_alert_ids) + data["alert_configurations"].extend(alerts_not_from_app) + + return jsonify(data) + + @app.route("/post", methods=["POST"]) def handle_alert() -> Tuple[Response, int]: """ @@ -146,7 +403,9 @@ def handle_alert() -> Tuple[Response, int]: r_status = 200 except KeyError as e: # If value doesn't exist, do not insert into table and produce error - app.logger.error(f"Alert POST request did not have one or more keys with data. Will process but will return 400: {e}") + logger.error( + f"Alert POST request did not have one or more keys with data. Will process but will return 400: {e}" + ) r_title = "Missing info in request" r_detail = "At least one value to insert in the database is missing in the post request" r_status = 400 @@ -166,7 +425,7 @@ def create_alert() -> Response: # initialize response headers r_alert_id: int = -1 r_success: bool = False - r_description: str = '' + r_description: str # Process json content_type = request.headers.get("Content-Type") data: Dict[str, Any] @@ -190,7 +449,7 @@ def create_alert() -> Response: if "max_weekly" not in data: data["max_weekly"] = 1000 - app.logger.info(f"Making AeroAPI request to POST {api_resource}") + logger.info(f"Making AeroAPI request to POST {api_resource}") result = AEROAPI.post(f"{AEROAPI_BASE_URL}{api_resource}", json=data) if result.status_code != 201: # return to front end the error, decode and clean the response