Build an App With Python, Flask, and MongoDB to Track UFOs
Rate this tutorial
Have you ever looked up at the night sky and wondered what’s out there? Even as our knowledge of the cosmos expands — especially with the launch of the James Webb Space Telescope and the spectacular stream of photos it sends back — so many mysteries remain. A perpetual and existential question is whether there is other intelligent life out there.
While this question remains unanswered, there are plenty of citizen documentarians out there tracking appearances of unidentified flying objects (UFOs). The National UFO Reporting Center (NUFORC) collects reports of UFO sightings and stores them in a publicly accessible data bank.
To explore the capabilities of MongoDB and the Pymongo driver used with Python and Flask, I built a full-stack web application that allows users to look up reports of UFO sightings based on NUFORC data as well as submit a report back to a central database in the cloud with MongoDB Atlas.
You can access the full codebase for the app, but follow along for the rest of the tutorial to learn how to build this on your own!
To be successful with this tutorial, you should already have the following ready to go:
- MongoDB Atlas, free tier or above
To get started, you’ll need to have the data from NUFORC uploaded to MongoDB Atlas. To do this, download a free scrubbed CSV file of this data available on Kaggle.
Log into Atlas and copy your connection string. Then, open up MongoDB Compass on your computer and click the “New Connection” button to enter your Atlas connection string and connect to your Atlas database. From there, create a database called “ufos” and a collection in that database, also called “ufos”. When you click on the “ufos” collection, you’ll be taken to a screen where you will upload the CSV file of UFO reports you downloaded from Kaggle.
Now you have a collection of UFO reports in the cloud with Atlas that you can use as part of your application.
Regardless of your chosen IDE and whether you’re working in a virtual environment or on your local machine, follow Flask best practices for organizing your workspace for building this app.
I’ve been exploring GitHub Codespaces lately and started building off of a sample Flask application there. But you may have the best experience using VS Code and the MongoDB VS Code Extension.
Create one folder for everything for your application called “ufo-spotter-app”. Within that folder, create one folder called
static
which will eventually contain the assets used by your templates. Create a second folder called templates
within the ufo-spotter-app
parent folder where your .html
template pages will live.Now that everything is set up in your development environment and you have your data uploaded to MongoDB, you’re ready to start coding! You’ll start by building out your templates. In Flask, templates contain static data as well as dynamic data that can be passed in with the help of the Jinja library in Flask. You can learn more about Flask templates in the Flask documentation.
The first template you’ll build is
layout.html
. This template will serve as the basis of the other templates, with Jinja making it easier to build off of layout.html
without having to write the same code again and again. See below for the code for layout.html
, which establishes the document type as html, the language as English, the default CSS styling coming from Bootstrap but with a link to a second main.css
file we’ll create next, and the navbar your users will rely on to navigate the website.1 <!DOCTYPE html> 2 3 <html lang="en"> 4 5 <head> 6 7 <meta charset="utf-8"> 8 <meta name="viewport" content="initial-scale=1, width=device-width"> 9 10 <!-- http://getbootstrap.com/docs/5.1/ --> 11 <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3" crossorigin="anonymous"> 12 <link href="/static/main.css" rel="stylesheet"> 13 14 <title> {% block title %} UFO Spotter {% endblock %}</title> 15 16 </head> 17 18 <nav class="navbar navbar-expand-lg navbar-dark bg-dark"> 19 <div class="container-fluid"> 20 <a href="/" class="navbar-brand"> UFO Spotter 21 </a> 22 <button type="button" class="navbar-toggler" data-bs-toggle="collapse" data-bs-target="#navbarCollapse"> 23 <span class="navbar-toggler-icon"></span> 24 </button> 25 <div class="collapse navbar-collapse" id="navbarCollapse"> 26 <div class="navbar-nav"> 27 <a href="/" class="nav-item nav-link active">Sightings by City</a> 28 29 <div class="collapse navbar-collapse" id="navbarCollapse"> 30 <a class="nav-item nav-link active">|</a> 31 <div class="navbar-nav"> 32 <div class="collapse navbar-collapse" id="navbarCollapse"> 33 <div class="navbar-nav"> 34 <a href="/submit.html" class="nav-item nav-link active">Report a Sighting </a> 35 </div> 36 </div> 37 </nav> 38 39 40 <main class="container-fluid py-5 text-center"> 41 {% block main %} 42 {% endblock %} 43 44 </main> 45 </html>
The html in this section creates the navbar with clickable sections at the upper left that will be consistent across all pages:
Now, let’s add a little flair to the default CSS styling we already have from Bootstrap. Create a file called
Main.css
in the “static” folder with the following code:1 body { 2 margin: 0; 3 font-family: American Typewriter, serif 4 } 5 6 .small { 7 font-size: 0.75rem; 8 } 9 10 .h3 { 11 margin-bottom: 10px; 12 13 } 14 15 footer { 16 position: absolute; 17 bottom: 0; 18 width: 100%; 19 height: 2.5rem; 20 }
The font choice of American Typewriter is intended to give off a minimalist and spooky vibe in line with the UFO theme, while still being easy to read.
Now we’re really getting into the meat of the application. The next thing we’re going to do is set up our web application to allow users to enter a U.S. city and state and get back statistics on UFO sightings as well as a list of the 10 most recent sightings. This way, they’ll know what to be on the lookout for.
Let’s make the html template that we’ll render to let users input a U.S. city and state. First you have to enter
{% extends "layout.html" %}
at the top to base this file off of layout.html. Then we’ve got a fun image (sourced from Flaticon), some text to tell website visitors how to use the page, and a form. The first section of the form lets a user enter text for the name of the city. The next uses a Jinja for loop to provide a select box for U.S. states based on a Python list of state abbreviations in the app.py file
. After that, we just have the “Submit” button to trigger the form completion and a footer at the bottom to provide appropriate attribution for the UFO image.1 {% extends "layout.html" %} 2 3 {% block title %} 4 Get UFO Reports 5 {% endblock %} 6 7 {% block main %} 8 9 <img src="/static/ufo_icons/ufo7.png" width="100" height="100" alt=""> 10 11 <h5>Enter City and State:</h5> 12 <form action="/" method="post"> 13 <p> 14 <input autocomplete="off" autofocus name="city" placeholder="City" type="text" required> 15 </p> 16 <p> 17 <select class="mx-auto" name="state" placeholder="State" style="width:auto;"> 18 19 {% for state in states %} 20 21 <option value="{{ state }}">{{ state }}</option> 22 23 {% endfor %} 24 </select> 25 26 27 </p> 28 <p> 29 <button type="submit" class="btn btn-primary">Submit</button> 30 </p> 31 </form> 32 33 <footer class="page-footer font-small blue"> 34 35 <div class="footer-attribution text-center"> 36 <a href="https://www.flaticon.com/free-icons/alien" title="alien icons">Alien icons created by Freepik - Flaticon</a> 37 38 </div> 39 40 </footer> 41 {% endblock %}
Here’s what the page looks like when rendered:
One of the great features of MongoDB is the document model, which gives you the flexibility to model your data as JSON-like documents that easily map to objects in your code. I’ve personally found this much easier than a relational approach using a myriad of different tables and managing table joins for different projects.
To support the main
app.py
file for this application, we will create a helpers.py
file with two helper functions that pull data from the MongoDB Atlas database using the MongoDB Query API.The first function,
get_count()
, uses the $match
and $count
aggregation stages in the Query API to find UFO reports with the same U.S. city and state as input by the user and returns the total number of UFO reports matching that city/state combination.The second function,
get_ufos()
, uses the $match
and $sort
aggregation stages. This function also finds UFO reports with the same U.S. city and state as input by the user, and then returns all of those reports sorted by the date of the sighting from most recent to oldest.1 import requests 2 3 4 from flask import redirect, render_template, request 5 from pymongo import MongoClient 6 7 8 client = MongoClient('YOUR_CONNECTION_STRING') 9 10 11 def get_count(city, state): 12 """Count the UFO sightings for that city and state""" 13 14 15 city_count = client['ufos']['ufos'].aggregate([ 16 { 17 '$match': { 18 'city': city 19 } 20 }, { 21 '$match': { 22 'state': state 23 } 24 }, { 25 '$count': 'ufo_count' 26 } 27 ]) 28 29 30 return city_count 31 32 33 def get_ufos(city, state): 34 """Gets report of UFO sightings for that city and state""" 35 36 37 recent_ufos = client['ufos']['ufos'].aggregate([ 38 { 39 '$match': { 40 'city': city 41 } 42 }, { 43 '$match': { 44 'state': state 45 } 46 }, { 47 '$sort': { 48 'datetime': -1 49 } 50 }, 51 ]) 52 53 54 return recent_ufos
Now let’s take a look at the first portion of the
app.py
file. First you need to import re
to allow for regular expressions. You’ll also need to import date
from datetime
to help with processing the dates related to sightings. You’ll get Flask for building your Flask app as well as render_template
to help with displaying templates and request
for taking user inputs from forms in your html templates. Finally, you’ll need to import the functions we built in helpers.py
.After a few more lines of code to set up your application, you’ll set up your
client
variable to connect to MongoDB Atlas with your connection string (make sure to substitute in your actual connection string for the placeholder in the code). And you’ll want to declare an array of U.S. state abbreviations to be displayed in city.html.1 import re 2 from datetime import date 3 import datetime 4 5 from flask import Flask, render_template, request 6 from pymongo import MongoClient 7 from helpers import get_count, get_ufos 8 9 app = Flask(__name__) 10 11 if __name__ == "__main__": 12 app.run() 13 14 client = MongoClient('YOUR_CONNECTION_STRING') 15 db = client['ufos'] 16 ufos = db.ufos 17 18 # Declare array of U.S. states to use in later functions 19 states = ["AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA","KS","KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ","NM","NY","NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT","VT","VA","WA","WV","WI","WY"]
Now that you’ve set this Python file as the core file at the heart of your Flask application, you’ll create your
city()
function with a decorator that assigns the URL and allows this function to receive both GET and POST HTTP requests.When receiving a GET request from the user, this function will display the city.html file we created earlier, passing in the states array for us in that file.
If the function is called with an HTTP POST request, we process the U.S. city and state input by the user once they hit the “Submit” button in
city.html
. This function also includes some error-handling and returns an appropriate error message to the user if the city name they submit includes a numeral or a special character, which it never should.Using the .lower() method in Python, we convert the uppercase abbreviation for the U.S. state to lowercase to match the format of the UFO reports data set.
We are able to get the number of UFO reports in the state specified by the user with the .count_documents() method in PyMongo. This is a handy method for when you just need to count the number of documents matching along a specific field, versus having to build a full aggregation pipeline with $match and $count to get that result.
Next, we get the number of UFO reports for that city/state combination using our
get_count()
helper method and a list of UFO sightings for that area with the get_ufos()
helper function. Then the number of sightings in that state is assigned to the variable state_count
.The number of sightings in that city/state combination is returned as a cursor with PyMongo, which we can later iterate over using a Jinja for loop. And finally, we get a cursor we’ll name
recent_ufos
(which we can also use to iterate over the group of sightings the cursor points to) that points to a list of sightings for that city/state. Then state_count
, city_count
, and recent_ufos
are passed to the results.html
template to be displayed to the user.1 @app.route("/", methods=["GET", "POST"]) 2 def city(): 3 4 # Let user submit city and state to look up UFO sightings 5 if request.method == "GET": 6 7 return render_template("city.html", states=states) 8 9 # Return and display results 10 if request.method == "POST": 11 12 # Get city and state from user 13 city = request.form.get("city") 14 state = request.form.get("state") 15 16 # Define error message 17 message = "the name of a valid U.S. city" 18 19 # Return error message if user enters number in city name 20 if request.form.get("city").isnumeric() is True: 21 return render_template("error.html", message=message) 22 23 # Return error message if user enters special character in city name 24 # Attribution: Found way to check for special characters with regex here: https://www.geeksforgeeks.org/python-program-check-string-contains-special-character/ 25 regex = re.compile('[@_!#$%^&*()<>?/\|}{~:]') 26 if regex.search(city) is not None: 27 message = "the name of a valid U.S. city" 28 return render_template("error.html", message=message) 29 30 # Convert city and state to lower case 31 state = state.lower() 32 33 # Count number of UFO sightings in state 34 state_count = ufos.count_documents({"state": state}) 35 36 # Get number of UFO sightings in city and state as a list 37 city_count = get_count(city, state) 38 39 # Get list of recent UFO sightings 40 recent_ufos = get_ufos(city, state) 41 42 return render_template("results.html", state_count=state_count, city_count=city_count, recent_ufos=recent_ufos)
Now that we have our html page where users can input their city and state, a function in our app.py file to process that information from the user, and some helper functions to support looking up the relevant data, we’re ready to display results for the user. The
results.html
page is displayed as the result of a successful POST request to the city()
function in app.py
. This page extends the layout.html
template and shows statistics and information on sightings for the U.S. city and state input by the user.We’ll display the value of
state_count
passed in from the city
view function for the total sightings in the state input by the user on this page. Next, we’ll use a Jinja for loop to loop over our list of dictionaries pointed to by the city_count
cursor, and pull the city dict's "ufo_count" value to display in the page.We’ll also use a Jinja for loop to loop through the list of dictionaries pointed to by the
recent_ufos
cursor. As we go through the loop, we can pull the specific values we’re looking to display for the sightings using Python's usual syntax for looking up a value in a dict.1 {% extends "layout.html" %} 2 3 {% block title %} 4 5 UFOs in Your City and State 6 7 {% endblock %} 8 9 {% block main %} 10 11 <h3>Local Stats:</h3> 12 <p> 13 <b>Total sightings in your state:</b> {{state_count}} 14 </p> 15 <p> 16 <b>Total sightings in your city:</b> 17 {% for city in city_count %} 18 {{city["ufo_count"]}} 19 {% endfor %} 20 </p> 21 22 23 <br> 24 25 <h3>Recent UFO Sightings:</h3> 26 27 <div class="row mx-auto"> 28 29 {% for ufo in recent_ufos %} 30 31 <div class="col-sm-6 mx-auto"> 32 <div class="card text-white bg-secondary mb-3"> 33 <div class="card-body"> 34 <p> 35 <b>Date of Sighting: </b>{{ufo['datetime']}} 36 </p> 37 <p> 38 <b>Date Reported </b>{{ufo['date posted']}} 39 </p> 40 <p> 41 <b>Shape: </b>{{ufo['shape']}} 42 </p> 43 <p> 44 <b>Duration (hours/minutes): </b>{{ufo['duration (hours/min)']}} 45 </p> 46 <p> 47 <b>Description: </b>{{ufo['comments']}} 48 </p> 49 </div> 50 </div> 51 </div> 52 53 {% endfor %} 54 55 {% endblock %}
Here’s how this page looks after searching for UFO reports in Birmingham, Alabama:
The last thing we’ll need to do in this section is create our error.html file, which the user sees if they don’t enter any text for the city name or a numeric or special character. This is a very simple html file with the error message passed in to it from
app.py
.1 {% extends "layout.html" %} 2 3 {% block title %} 4 Error 5 {% endblock %} 6 7 {% block main %} 8 <body> 9 You must submit {{message}}. 10 </body> 11 {% endblock %}
The next step we’ll take to build this out even further is adding functionality for users to input their own UFO sightings to the database for others to reference in the future.
At this point, we have a fully functional Flask web application where users can submit a city and state, read from the MongoDB Atlas database, and get statistics and a list of sightings back. Now, let’s add functionality to write to the database with a function and supporting html pages that let users add their own UFO sightings.
Similar to the our
city()
function we’ve already created in app.py, our new submit()
function will take both GET and POST requests.The first thing we’ll set up in this function is processing our GET request. A GET request here leads to the display of the
submit.html
page, which will display a form with the relevant fields for the user to submit their sighting. We’ll pass in the array of U.S. states we used earlier for the state select field in the form.For our POST request, the first thing we’ll do is create an empty Python dictionary called
ufo_report
to hold the report the user is submitting.Next, we’ll use the
request.form.get()
function to pull the values the user added to the form and assign them to variables. Using date.today()
, we can pull today’s month, day, and year to get the date of the report submission without the user having to manually enter it. Then, using today.strftime()
, we put it in the right format to match the rest of the data set.One important thing to note is that it is generally best practice to store date and time data with Python datetime.datetime objects (PyMongo will save these as native MongoDB date types) rather than as a string as we do here. However, for simplicity in working with the public data set from NUFORC, we will be storing this information as strings instead. This way, you won’t need to download the data from NUFORC and convert all of the strings from the data set into the proper object type for use in this tutorial.
Similar to our function that lets our users look up sightings by city and state, we’ll want to check for incorrect entries in the state field in the form. We’ll use
isnumeric()
to check if any numbers have been entered in the city name and a regular expression to check for special characters. We’ll then display an error message if anything is amiss.Now that we have our variables pulled from the form, we will combine them in our empty python dictionary with the assignment operator and specify the appropriate keys for each value. Since this application only looks at UFO sightings in the U.S. (for now), we’ll manually set the country abbreviation as “us”. Once our dictionary for the UFO report is complete, we insert it into our ufos collection in our MongoDB Atlas database with
ufos.insert_one(ufo_report)
.After our report has been added, we’ll display a simple thank-you message to the user in
thank_you.html
.1 @app.route("/submit.html", methods=["GET", "POST"]) 2 def submit(): 3 4 # Display form for user to submit report of UFO sighting 5 if request.method == "GET": 6 7 return render_template("submit.html", states=states) 8 9 # Get data on UFO sighting from form and add to MongoDB 10 if request.method == "POST": 11 12 # Create empty Python dictionary to hold UFO report submitted by form 13 ufo_report = {} 14 15 # Get UFO report information from user form submission 16 city = request.form.get("city") 17 state = request.form.get("state").lower() 18 shape = request.form.get("shape") 19 duration_seconds = request.form.get("duration_seconds") 20 duration_minutes = request.form.get("duration_minutes") 21 comments = request.form.get("comments") 22 sighting_date = request.form.get("datetime") 23 latitude = request.form.get("latitude") 24 longitude = request.form.get("longitude") 25 26 # convert string style date into datetime format 27 sighting_date = datetime.datetime.strptime(sighting_date, "%Y-%m-%dT%H:%M") 28 29 # Get date that user submitted the form 30 # Attribution: Found this way to convert time of form submission here: https://www.programiz.com/python-programming/datetime/current-datetime 31 today = date.today() 32 today = today.strftime("%m/%d/%y") 33 34 # Return error message if user enters number in city name 35 if request.form.get("city").isnumeric() is True: 36 return render_template("error.html", message=message) 37 38 # Return error message if user enters special character in city name 39 # Attribution: Found way to check for special characters with regex here: https://www.geeksforgeeks.org/python-program-check-string-contains-special-character/ 40 regex = re.compile('[@_!#$%^&*()<>?/\|}{~:]') 41 if regex.search(city) is not None: 42 message = "the name of a valid U.S. city" 43 return render_template("error.html", message=message) 44 45 # Compile report details into Python dictionary 46 ufo_report["datetime"] = sighting_date 47 ufo_report["city"] = city 48 ufo_report["state"] = state 49 ufo_report["shape"] = shape 50 ufo_report["duration (seconds)"] = duration_seconds 51 ufo_report["duration (hours/min)"] = duration_minutes 52 ufo_report["comments"] = comments 53 ufo_report["country"] = "us" 54 ufo_report["date posted"] = today 55 ufo_report["latitude"] = latitude 56 ufo_report["longitude"] = longitude 57 58 # Add UFO sighting report to MongoDB Atlas database 59 ufos.insert_one(ufo_report) 60 61 # Return thank-you message after report submission 62 return render_template("thank_you.html")
If you wanted to do some extra credit, you could also model the latitude and longitude values as a GeoJSON Point type and index it for GIS queries. See how in the MongoDB documentation.
Next, we’ll create the html page that’s displayed when the user submits a GET request to the
submit()
function. This page displays a form where the users can submit a record of their own UFO sighting. The design of the form is largely drawn from Bootstrap. As we did the city.html
page, we’ll pass in the states array and create a Jinja loop to allow users to select a state from that array.1 {% extends "layout.html" %} 2 3 {% block title %} 4 Submit UFO Sighting Report 5 {% endblock %} 6 7 {% block main %} 8 <h5>Report a UFO Sighting</h5> 9 10 <br> 11 12 <form action="/submit.html" method="post"> 13 <div class="form-group"> 14 <b>City:</b> <input autocomplete="off" autofocus name="city" id="city" type="text" required> 15 </div> 16 17 <br> 18 19 <div class="form-group"> 20 21 <b>State:</b> 22 <select class="mx-auto" name="state" id="state" placeholder="State" style="width:auto;"> 23 24 {% for state in states %} 25 26 <option value="{{ state }}">{{ state }}</option> 27 28 {% endfor %} 29 </select> 30 </div> 31 32 <br> 33 34 <div class="form-group"> 35 <b>Latitude:</b> <input autocomplete="off" autofocus id="latitude" name="latitude" type="numeric" required> 36 <b>Longitude:</b> <input autocomplete="off" autofocus id="longitude" name="longitude" type="numeric" required> 37 </div> 38 <p> 39 Don't know your latitude and longitude? <a href="https://www.latlong.net/">Look it up here.</a> 40 </p> 41 42 <br> 43 44 <div class="form-group"> 45 <b>Duration (seconds):</b> <input autocomplete="off" autofocus id="duration_seconds" name="duration_seconds" type="numeric"> 46 </div> 47 48 <br> 49 50 <div class="form-group"> 51 <b>Duration (hours/minutes):</b> <input autocomplete="off" autofocus id="duration_minutes" name="duration_minutes" type="text" required> 52 </div> 53 54 <br> 55 56 <div class="form-group"> 57 <b>Shape:</b> <input autocomplete="off" autofocus id="shape" name="shape" type="text" required> 58 </div> 59 60 <br> 61 62 <div class="form-group"> 63 <label><b>Date and Time:</b></label> 64 <input placeholder="datetime" name="datetime" type="datetime-local" required> 65 </div> 66 67 <br> 68 69 <div class="row d-flex justify-content-center"> 70 <div class="form-group" style="width:50%"> 71 <label><b>Comments:</b></label> 72 <textarea class="form-control" rows="2" name="comments" id="comments"></textarea> 73 </div> 74 </div> 75 76 <br> 77 78 <button type="submit" class="btn btn-primary">Submit</button> 79 </form> 80 {% endblock %}
Here’s how this page looks when rendered:
This page uses Jinja to extend the
layout.html
template and display a simple (and fun) text thank-you message after the user successfully submits a UFO sighting report.1 {% extends "layout.html" %} 2 3 {% block title %} 4 Thank You 5 {% endblock %} 6 7 {% block main %} 8 9 <p> 10 Thank you for submitting your report. Live long and prosper. 🖖 11 </p> 12 {% endblock %}
We may not be able to verify that aliens are out there in space. But we can track UFOs in cyberspace with MongoDB, Python, and Flask.
With the ease of use of Python and Flask, and the flexibility of MongoDB’s document model, we’ve now built an app to let users find statistics about UFO sightings in their area, get recent UFO sighting reports, and submit a sighting if they are lucky (or unlucky) enough to have an alien encounter of their own.
See the full codebase for this tutorial on GitHub. If you thought this material was valuable, be sure to rate this tutorial below and share a link on social!
Thank you to Shubham Ranjan for contributing edits and bug fixes to this tutorial and supporting code.