diff --git a/alerts_backend/python/app.py b/alerts_backend/python/app.py index 0e4a379..24f3a2c 100644 --- a/alerts_backend/python/app.py +++ b/alerts_backend/python/app.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import Dict, Any, Tuple, Set +import copy import json import requests from flask import Flask, jsonify, Response, request @@ -11,8 +12,8 @@ from sqlalchemy import (exc, create_engine, MetaData, Table, Column, Integer, Boolean, Text, insert, - Date, DateTime, delete) -from sqlalchemy.sql import func + Date, DateTime, delete, update) +from sqlalchemy.sql import func, and_ from sqlalchemy import ( exc, create_engine, @@ -25,6 +26,7 @@ insert, Date, select, + exists ) AEROAPI_BASE_URL = "https://aeroapi.flightaware.com/aeroapi" @@ -50,41 +52,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(): @@ -141,6 +144,24 @@ def delete_from_table(fa_alert_id: int): 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: + 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 @@ -153,10 +174,10 @@ def get_alerts_not_from_app(existing_alert_ids: Set[int]): logger.info(f"Making AeroAPI request to GET {api_resource}") result = AEROAPI.get(f"{AEROAPI_BASE_URL}{api_resource}") if not result: - return None + return [] all_alerts = result.json()["alerts"] if not all_alerts: - return None + return [] alerts_not_from_app = [] for alert in all_alerts: if int(alert["id"]) not in existing_alert_ids: @@ -183,6 +204,92 @@ def get_alerts_not_from_app(existing_alert_ids: Set[int]): return alerts_not_from_app +def check_if_dup(alert_data) -> bool: + """ + Check if given alert is a duplicate alert configured. Do this by checking the + SQLite database. Return True if duplicate, False if not. + """ + try: + with engine.connect() as conn: + stmt = select(aeroapi_alert_configurations).where(and_( + aeroapi_alert_configurations.c.ident == alert_data["ident"], + aeroapi_alert_configurations.c.destination == alert_data["destination"], + aeroapi_alert_configurations.c.origin == alert_data["origin"], + aeroapi_alert_configurations.c.aircraft_type == alert_data["aircraft_type"], + )) + result = conn.execute(stmt) + conn.commit() + return result.all() + except exc.SQLAlchemyError as e: + logger.error(f"SQL error occurred in checking for duplicate alert in table {aeroapi_alert_configurations.name}: {e}") + raise e + + +@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(): """ @@ -307,7 +414,8 @@ 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 - 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 @@ -351,48 +459,52 @@ def create_alert() -> Response: if "max_weekly" not in data: data["max_weekly"] = 1000 - 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 - try: - processed_json = result.json() - r_description = f"Error code {result.status_code} with the following description: {processed_json['detail']}" - except json.decoder.JSONDecodeError: - r_description = f"Error code {result.status_code} could not be parsed into JSON. The following is the HTML response given: {result.text}" + # Check if alert is duplicate + if check_if_dup(data): + r_description = f"Ticket error: alert has already been configured. Ticket has not been created" else: - # Package created alert and put into database - fa_alert_id = int(result.headers["Location"][8:]) - r_alert_id = fa_alert_id - # Flatten events to insert into database - data["arrival"] = data["events"]["arrival"] - data["departure"] = data["events"]["departure"] - data["cancelled"] = data["events"]["cancelled"] - data["diverted"] = data["events"]["diverted"] - data["filed"] = data["events"]["filed"] - data.pop("events") - # Rename dates to avoid sql keyword "end" issue, and also change to Python datetime.datetime() - # Default to None in case a user directly submits an incomplete payload - data["start_date"] = data.pop("start", None) - data["end_date"] = data.pop("end", None) - # Allow empty strings - if data["start_date"] == "": - data["start_date"] = None - if data["end_date"] == "": - data["end_date"] = None - # Handle if dates are None - accept them but don't parse time - 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") - - data["fa_alert_id"] = fa_alert_id - - if insert_into_table(data, aeroapi_alert_configurations) == -1: - r_description = f"Database insertion error, check your database configuration. Alert has still been configured with alert id {r_alert_id}" + 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 + try: + processed_json = result.json() + r_description = f"Error code {result.status_code} with the following description: {processed_json['detail']}" + except json.decoder.JSONDecodeError: + r_description = f"Error code {result.status_code} could not be parsed into JSON. The following is the HTML response given: {result.text}" else: - r_success = True - r_description = f"Request sent successfully with alert id {r_alert_id}" + # Package created alert and put into database + fa_alert_id = int(result.headers["Location"][8:]) + r_alert_id = fa_alert_id + # Flatten events to insert into database + data["arrival"] = data["events"]["arrival"] + data["departure"] = data["events"]["departure"] + data["cancelled"] = data["events"]["cancelled"] + data["diverted"] = data["events"]["diverted"] + data["filed"] = data["events"]["filed"] + data.pop("events") + # Rename dates to avoid sql keyword "end" issue, and also change to Python datetime.datetime() + # Default to None in case a user directly submits an incomplete payload + data["start_date"] = data.pop("start", None) + data["end_date"] = data.pop("end", None) + # Allow empty strings + if data["start_date"] == "": + data["start_date"] = None + if data["end_date"] == "": + data["end_date"] = None + # Handle if dates are None - accept them but don't parse time + 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") + + data["fa_alert_id"] = fa_alert_id + + if insert_into_table(data, aeroapi_alert_configurations) == -1: + r_description = f"Database insertion error, check your database configuration. Alert has still been configured with alert id {r_alert_id}" + else: + r_success = True + r_description = f"Request sent successfully with alert id {r_alert_id}" return jsonify({"Alert_id": r_alert_id, "Success": r_success, "Description": r_description})