Recently, after a long day of work and school, my family decided to go out to eat. There was just one problem. No one wanted to decide where we were going to eat. Therefore, I had the idea to build an API that would allow me to request a small number of restaurants. These should be weighted based on our family preferences (how often we choose the restaurant) but not restaurants that we have eaten at recently. Normally, I would start with Node.js. However, JavaScript is not my favorite language. Therefore, I decided to give Flask a try. It is safe to say that it was enjoyable to use Flask, and I don’t think that I will need Node.js again.
What is Flask?
Flask is a lightweight web framework for Python. It’s built on WSGI (Web Server Gateway Interface), which is a standard interface between web servers and Python web applications or frameworks. It is easy to get started with Flask, but you can use it for more complex projects too.
People often call Flask a “microframework” because unlike alternatives like Django, Flask is not “batteries included”. For example, Flask doesn’t have a built in ORM (Object Relationship Mapper) for databases. Instead, Flask gives you the core tools you need for web development, and then you get to pick and choose whatever other libraries or tools you want to use for everything else. For me, this wasn’t much of a limitation, because I could easily import the sqlite library for database functions.
Restaurant Picker Requirements
At a high level, we want to create an API that has two endpoints:
- get_restaurants: returns 3 suggested restaurants
- add_restaurant: adds a new restaurant to the pool of potential restaurants (I will save this for a future post)
Since this will be used by my family, my database needs are not substantial. I don’t need concurrency or scalability, so I chose to use sqlite.
The Database
The database will have a table called restaurants with the following fields.
- restaurant_id (our primary key)
- restaurant_name
- dinein_weight: this is a dynamic weight that changes over time based on whether we pick or reject the restaurant when suggested.
- delivery_weight: although not implemented yet, eventually I want to be able to request either dine-in or delivery and get specific recommendations
- num_picked: how many times was the restaurant picked
- num_rejected: how many times rejected
Here is the SQL statement to create the table:
BEGIN TRANSACTION;
CREATE TABLE IF NOT EXISTS "restaurant" (
"restaurant_id" INTEGER NOT NULL UNIQUE,
"restaurant_name" TEXT,
"dine_in_weight" REAL DEFAULT 0.5,
"delivery_weight" REAL DEFAULT 0.5,
"num_picked" INTEGER DEFAULT 0,
"num_rejected" INTEGER DEFAULT 0,
PRIMARY KEY("restaurant_id" AUTOINCREMENT)
);
COMMIT;
Structure of a Flask App
I will be running this application on an Apache2 server. I have not tried it yet on alternative webservers. On Apache there are three key parts to a Flask App.
- Apache configuration file
- WSGI file
- The rest of the python code
The Configuration File
Starting with the configuration file, this file tells Apache what to look for in an incoming URL. In my case, I am not currently running anything else on the web server, so I send all traffic. However, if I had other services running, I might make a subdomain or change the port to something other than 80. This configuration file points to the WSGI file as well as the python virtual environment we want to use to run the app.
We also specify the user and group to run the app. This is really important for permissions to the folders where the app is placed as well as the database. I spent a significant amount of time debugging permissions.
<VirtualHost *:80>
ServerName 192.168.1.201
WSGIDaemonProcess restaurant_picker user=www-data group=www-data threads=1 python-home=/var/www/RestaurantPicker/picker_venv
WSGIScriptAlias / /var/www/RestaurantPicker/wsgi.py
<Directory /var/www/RestaurantPicker>
WSGIProcessGroup restaurant_picker
WSGIApplicationGroup %{GLOBAL}
Order deny,allow
Allow from all
</Directory>
ErrorLog ${APACHE_LOG_DIR}/error.log
CustomLog ${APACHE_LOG_DIR}/access.log combined
</VirtualHost>
This file resides in the apache2/sites-enabled directory. If you make a change to either this file or the WSGI file, you need to restart the server.
sudo systemctl restart apache2
I don’t cover in this post, but this runs the Flask app in a Python virtual environment named picker_venv. You can create this in the RestaurantPicker folder through the following command:
python -m vent picker_venv
Make sure to install all necessary packages in this environment.
WSGI
Next, let’s move onto the WSGI file. The Web Server Gateway Interface (or WSGI) for Python is specified in PEP3333. As the name implies, WSGI is an interface that sits between the web server and the Python application. The benefit of the standard is that you can switch out a web server (i.e., Apache to Nginx) without changing the Python application code.
There are two basic components to WSGI:
- Create the application
- Define the routes
I separated these components into separate files. I could have one file that did both. However, I plan to add additional Flask applications (hopefully a future post). When I have multiple Flask applications, I will replace the code that creates the application but can keep the route code the same.
Here is wsgi.py:
import sys
import os
from flask import request
# Add your application's directory to the Python path
sys.path.append(os.path.dirname(__file__))
from RestaurantPicker import picker
application = picker.restapp
Note that before we import the RestaurantPicker package, we add the location of the wsgi file to the Python path. This is because the instance of Python running our wsgi file may be located in another path. Alternatively, we could add the RestaurantPicker package permanently to path by installing the package.
Here is picker.py:
from flask import Flask, Response, request, render_template, jsonify
from . import eatpicker
# Simple REST API - request restaurants and return 3 names
restapp = Flask(__name__)
############################################################
# API routes
############################################################
@restapp.route('/restaurants', methods=['GET'])
def get_restaurants():
restaurant_list = eatpicker.get_restaurants_weighted(2)
new_list = eatpicker.get_restaurants(1, restaurant_list)
restaurant_list += new_list
return jsonify(sorted(restaurant_list))
The Picking
Now that we have a route from the webserver to our app, the next step is the actual work to pick a restaurant.
Here is the code:
import sqlite3
import datetime
import numpy
import random
import os
from . import console_error_tools
database_name = os.path.dirname(__file__) + '/epdb.db'
class Restaurants:
'''Class used to create lists of restaurants and weights. This is very simplistic. It has two instance variables which are lists
of the restaurants and thier corresponding weights. This class is used primarily to pick restaurants (weighted and unweighted).
'''
def __init__(self):
self.restaurant_id = []
self.name = []
self.weight = []
def add_restaurant(self, restaurant_id, name:str, weight:float):
self.restaurant_id.append(restaurant_id)
self.name.append(name)
self.weight.append(float(weight))
def get_restaurants(num:int, exclude_list=None):
'''Get a given number of restaurants (without weighting) Excluding from the selection the exclude list.
Parameters:
------
num : int
Number of restaurants to pick
exclude_list : list
List of restaurant names to exclude from selection (presumably because we already ran the weighted selection but
want to add an unweighted option.)
Returns:
------
list
List of picked restaurant names
'''
print('Exclude list: ' + str(exclude_list))
db = sqlite3.connect(database_name)
cursor = db.cursor()
# Create the exclusion clause
if exclude_list is not None and len(exclude_list) > 0:
if len(exclude_list) == 1:
# Special case to remove trailing comma
new_exclude_list = '(\'' + exclude_list[0] + '\')'
else:
new_exclude_list = str(tuple(exclude_list))
exclusion = ' WHERE restaurant_name NOT IN ' + new_exclude_list + ';'
else:
exclusion = ';'
# Get the list of restaurants
statement = 'SELECT restaurant_id, restaurant_name, dine_in_weight FROM restaurant' + exclusion
print(statement)
cursor.execute(statement)
# Create a Restaurant class and add each restaurant to the class
restaurant_list = Restaurants()
for row in cursor:
restaurant_list.add_restaurant(*row)
db.close()
if len(restaurant_list.name) < num:
console_error_tools.fumble('Warning', 'Not enough restaurants, limiting to available restaurants', hint='Add more restaurants or reduce the requested number of picks')
return restaurant_list.name
selections = numpy.random.choice(a=restaurant_list.name, size=num, replace=False)
return selections.tolist()
def get_restaurants_weighted(num:int):
'''Get a given number of restaurants (with weighting)
Parameters:
------
num : int
Number of restaurants to pick
Returns:
------
list
List of picked restaurant names
'''
print('Database name: ' + database_name)
db = sqlite3.connect(database_name)
cursor = db.cursor()
# Get the list of restaurants
statement = 'SELECT restaurant_id, restaurant_name, dine_in_weight FROM restaurant;'
cursor.execute(statement)
restaurant_list = Restaurants()
for row in cursor:
restaurant_list.add_restaurant(*row)
db.close()
# Check for enough restaurants
if len(restaurant_list.name) < num:
selections = restaurant_list.name
else:
selections = numpy.random.choice(a=restaurant_list.name, size=num, replace=False, p=numpy.array(restaurant_list.weight)/sum(restaurant_list.weight))
selections = selections.tolist()
# Use numpy to select (no replacement)
return selections
We will walk through the weighted picker function. The unweighted version is mostly the same. We just add an exclude list to allow us to exclude the weighted restaurants that were picked.
We start by getting a list of all of the restaurants from the database.
db = sqlite3.connect(database_name)
cursor = db.cursor()
# Get the list of restaurants
statement = 'SELECT restaurant_id, restaurant_name, dine_in_weight FROM restaurant;'
cursor.execute(statement)
Next, we unpack each row and append to two lists, one for the restaurant names and one for the weights. We use a simple Restaurant class for this purpose.
class Restaurants:
'''Class used to create lists of restaurants and weights. This is very simplistic. It has two instance variables which are lists
of the restaurants and thier corresponding weights. This class is used primarily to pick restaurants (weighted and unweighted).
'''
def __init__(self):
self.restaurant_id = []
self.name = []
self.weight = []
def add_restaurant(self, restaurant_id, name:str, weight:float):
self.restaurant_id.append(restaurant_id)
self.name.append(name)
self.weight.append(float(weight))
restaurant_list = Restaurants()
for row in cursor:
restaurant_list.add_restaurant(*row)
To unpack the result into the Restaurant class, we use the Python splat operator (*row). This is a cool feature of Python that expands a list or tuple (or other iterable object) into positional functional arguments. Then, we feed the two lists into the numpy.random.choices function, which allows us to pick from a weighted list with no replacement (i.e., we can’t pick the same restaurant twice).
# Check for enough restaurants
if len(restaurant_list.name) < num:
selections = restaurant_list.name
else:
selections = numpy.random.choice(a=restaurant_list.name, size=num, replace=False, p=numpy.array(restaurant_list.weight)/sum(restaurant_list.weight))
selections = selections.tolist()
In my route code above, I select two restaurants from the weighted list and one randomly from the full list (excluding the two I already picked). The weighted restaurants help to ensure our favorite restaurants are suggested more often. However, I include an equal weight selection to balance favorite restaurants with new discovery. Who knows, maybe this low probability restaurant will become a new favorite.
restaurant_list = eatpicker.get_restaurants_weighted(2)
new_list = eatpicker.get_restaurants(1, restaurant_list)
restaurant_list += new_list
Unit Testing
Normally, for my small projects, I do not create unit tests. However, this time I wanted to try GenAI to code the unit tests. In Visual Studio Code, this is incredibly easy. You simply right click on the function, select generate code, and select generate tests.
I loved using GenAI for this purpose for two reasons. First, I have unit tests that I can run every time I make a change to ensure the code is working correctly. Second, I learned something from the tests AI wrote. Instead of testing using the production database, the test creates a new database with known entries. The test code temporarily redirects the code to use the test database. Coming from a C++ background, I still forget that Python has no namespaces or private variables. Therefore, even though the database is defined in another file, you can change it in the test code.
Permissions
With our code written, the final step is running it on the web server. For me, this took the longest time due to permissions. I struggled to get everything running.
I will spare you the pain I felt and just give you the permissions that work for me. First, I change the ownership of the RestaurantPicker folder (and all child folders and files) to the Apache user, which in my case is www-data.
sudo chown -R www-data:www-data /var/www/RestaurantPicker/
Next, I change the user permissions (recursively) to 770. This gives read, write, execute to the user and group and no permissions to other users.
sudo chmod -R 775 /var/www/RestaurantPicker/
Finally, we set the group id. This is not strictly necessary in this case, because our code doesn’t create new files or directories. However, I have other apps that do create new files and I want the new files to have the same group as the directory.
sudo chmod g+s /var/www/RestaurantPicker/
Running the App
To run the app, copy the files into place. Set the permissions and restart the webserver.
sudo systemctl restart apache2
To access the restaurants, open a browser and type the address plus the route. For me, that looks like:
http://192.168.1.201/restaurants
It is not really convenient to type this into a browser every time, so I created a shortcut on my iPhone that queries the server and returns the result in a more friendly way.
What Next?
I really enjoyed how simple Flask is to use. I now have so many ideas for other APIs that would be useful in my daily life. Therefore, I want to add more endpoints with other applications (hopefully a future post). Specifically for the restaurant picker, I would like to add the functionality to select a restaurant and update the weights.
I would love to hear what types of applications you have made from Flask or suggestions on additional functionality I could add to the restaurant picker. Drop a comment below.