Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prevent Duplicate Alerts Created #16

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
268 changes: 190 additions & 78 deletions alerts_backend/python/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -25,6 +26,7 @@
insert,
Date,
select,
exists
)

AEROAPI_BASE_URL = "https://aeroapi.flightaware.com/aeroapi"
Expand All @@ -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():
Expand Down Expand Up @@ -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
Expand All @@ -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 []

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

minor change in function doc string to indicate empty list return instead of None

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:
Expand All @@ -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": <A detailed description of the response>}
"""
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():
"""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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})

Expand Down