Learn how to build a Flask-Admin management dashboard that reads booking data from SQLite, uses Pandas to analyze bookings and revenue, and presents key business metrics and charts for management.
Prerequisite:
This tutorial is part of the Flask Landing Page and Reservation System Series.
📚 View the Complete Flask Landing Page and Reservation System Series
⬅ Previous Part ➡ Next Part
Preliminary:
Before I begin, please activate the virtual environment and install the required dependencies.
python -m venv venv
venv\Scripts\activate
pip install flask, flask-admin, tinydb, matplotlib, pandas, flask-sqlalcheny
Then, we need to set up the file and folder structure as below:I have created app.py and dashboard.html under the templates folder. Database.json is retrieved from the previous tutorial. Meanwhile, the jcpc.db will be generated automatically.

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import Mapped, mapped_column
app = Flask(__name__)
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///jcpc.db'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['SECRET_KEY'] = 'here is my secret key'
db = SQLAlchemy(app)
class Booking(db.Model):
__tablename__ = 'bookings'
id: Mapped[int] = mapped_column(primary_key=True,
autoincrement=True)
username: Mapped[str] = mapped_column()
court: Mapped[str] = mapped_column()
date: Mapped[str] = mapped_column()
time_slot: Mapped[str] = mapped_column()
status: Mapped[str] = mapped_column()
payment: Mapped[str] = mapped_column()
amount: Mapped[float] = mapped_column()
stripe_session: Mapped[str] = mapped_column()
with app.app_context():
db.create_all()1. Create the Flask application- Creates my Flask application and tells Flask where the application is located.
- This tells SQLAlchemy to use SQLite and name the database jcpc.db. It means SQLite is being used as the database engine.
3. Disable SQLAlchemy modification trackingThis disables an additional SQLAlchemy feature that tracks object modifications.
- Flask uses the secret key to help protect the session data.
- For a real production application, don't hard-code your secret key in source code. Use an environment variable instead. However, for simplicity in this tutorial, I will hard-code my secret key.
5. Create the SQLAlchemy object- db
becomes the object I use to define and interact with my database.
- I'm telling SQLAlchemy, "Create a database table based on this Python class."
- This tells SQLAlchemy that the database table should be called "bookings."
- The id column creates an integer column called "id," and a primary key uniquely identifies each booking. While autoincrement=True means the database automatically generates the next ID.
- The username column creates a string column.
- The court column creates a string column.
- The date column creates a string column.
- The time slot column, again, is also stored as a string.
- The status column includes pending, confirmed, cancelled, and completed in a string column.
- The payment column includes paid, unpaid, and refunded.
- The amount column stores the booking amount, and Python expects a floating-point number.
- The Stripe session column stores the Stripe Checkout Session ID, and this allows me to determine which Stripe transaction belongs to which booking.
- Flask functionality needs to know the following: Which Flask application am I working with? Therefore, Flask automatically provides the context that this is the application I'm working with and creates tables if they don't exist.
from flask_admin import Admin, AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView
from flask_admin.theme import Bootstrap4Theme
class MyAdminIndexView(AdminIndexView):
@app.route("/admin/import")
def import_bookings():
df = pd.read_json("database.json")
data = df["bookings"].to_dict()
for item in data.values():
booking = Booking(
username=item["username"],
court=item["court"],
date=item["date"],
time_slot=item["time_slot"],
status=item["status"],
payment=item["payment"],
amount=item["amount"],
stripe_session=item["stripe_session"]
)
db.session.add(booking)
db.session.commit()
return "Bookings imported successfully"
class BookingView(ModelView):
column_list = (
"id",
"username",
"court",
"date",
"time_slot",
"status",
"payment",
"amount",
"stripe_session",
)
admin.add_view(
BookingView(
Booking,
db.session,
name="Bookings"
)
)This function is mainly an import function that takes booking data from database.json and inserts it into my SQLAlchemy Booking table.- I will create my own admin index view by inheriting from Flask-Admin's AdminIndexView.
- This creates a normal Flask route that is responsible for importing the bookings.
- This reads database.json using Pandas. Therefore, Pandas reads that JSON into a DataFrame.
- This is doing two things.
- First, it selects the bookings column/series.
- Then it converts it into a Python dictionary.
- It means going through every booking one at a time.
- Now this is where the JSON data gets converted into my SQLAlchemy model according to my model in step 1 above.
- I will essentially be telling SQLAlchemy: "I want to insert this Booking object." Therefore, the object is added to SQLAlchemy's session, and the database change is committed.
- After the import finishes, the browser receives the following message: Bookings imported successfully.
- It tells Flask-Admin: "I want to create an admin interface for my
Bookingmodel," and Flask-Admin generates the following interface for me, which includes the following: - viewing bookings
- listing bookings
- editing bookings
- deleting bookings
10. column_list
- This tells Flask-Admin which columns to display in the admin list. Therefore, without Flask-Admin, it may automatically determine which fields to display.
- This connects my Booking model to Flask-Admin and uses this SQLAlchemy database session.
🎁 Get Your FREE Flask Cheat Sheet
Get more Flask, Python automation, Docker, and HTMX tutorials delivered to your inbox.
✓ Practical coding tutorials
✓ Automation tips for SMEs
✓ New project ideas and templates
Download my FREE Flask Cheat Sheet (PDF)

from flask_admin import Admin, AdminIndexView, expose
from flask_admin.contrib.sqla import ModelView
from flask_admin.theme import Bootstrap4Theme
class MyAdminIndexView(AdminIndexView):
@expose("/")
def index(self, **kwargs):
bookings = db.session.scalars(select(Booking)).all()
print(f"Bookings from database: {bookings}")
df = pd.DataFrame([
{
"username": booking.username,
"court": booking.court,
"date": booking.date,
"time_slot": booking.time_slot,
"status": booking.status,
"payment": booking.payment,
"amount": booking.amount,
"stripe_session": booking.stripe_session
}
for booking in bookings
])
total_bookings = f"{len(df)}"
total_revenue = df["amount"].sum()
formatted_revenue = f"{total_revenue:.2f}"
bookings_by_court = (
df.groupby("court")
.size()
.to_dict()
)
total_customers = len(df['username'].unique())
repeat_customers = (df["username"].value_counts() > 1).sum()
chart_df = pd.DataFrame(
list(bookings_by_court.items()),
columns=["court", "bookings"]
)
fig = chart_df.plot.bar(
x = 'court',
y = "bookings",
title = "Bookings by Court",
grid= True,
rot = 45,
mark_right=True,
figsize=(7,3)
).get_figure()
img = io.BytesIO()
fig.savefig(img, format="png", bbox_inches="tight")
img.seek(0)
chart = base64.b64encode(img.getvalue()).decode("utf-8")
return self.render(
"dashboard.html",
total_bookings=total_bookings,
total_revenue=formatted_revenue,
total_customers=total_customers,
repeat_customers=repeat_customers,
bookings_by_court=bookings_by_court,
chart=chart
)
admin = Admin(app,
name="JCPC Admin",
theme=Bootstrap4Theme(swatch='slate'),
index_view=MyAdminIndexView())1. @expose("/")- It tells Flask-Admin
that when the user visits the main page of this admin view, execute index(). So if my admin URL is http://127.0.0.1:5000/admin/,
- It means selecting all records from the Booking model, and SQLAlchemy returns the actual Booking objects rather than rows containing extra structure.
- This is a list comprehension. It means that I will be taking every SQLAlchemy
Bookingobject and converting it into a Python dictionary. An example below.
SQLAlchemy Booking object
Booking(
username="Kelvin",
court="St George",
amount=20
)
Python dictionary
{
"username": "Kelvin",
"court": "St George",
"amount": 20
}
- len(df) gives me the number of rows. For example: 10 bookings. Then, it will convert it into a string: '10'.
- This selects the amount column, df["amount"], and calculates the total: .sum().
- .2f means "display the number with exactly two decimal places."
- It groups the rows by court. An example: Court: St. George, and the users: Kelvin, John, and Sarah.
- Then, .size() will count how many bookings are in each group: St. George: 3.
- Finally, .to_dict() converts it to {"St George": 3}.
- This counts unique usernames. There are five bookings, but only three unique customers: Kelvin, John, and Mary. So, it produces something like ["Kelvin", "John", "Mary"] and gives 3 unique customers.
- First, count how many bookings each customer has. For example:
Kelvin 3
John 1
Mary 2
- Then, it checks which customers have more than one booking. Finally, Kelvin and Mary meet the criteria. So, the repeat customer count is 2.
- First: The "bookings_by_court.items()"" function will generate as follows:
("St George", 3)
("St Louis", 1)
("St Mark", 1)- However, list(...) converts them into a list:
[
("St George", 3),
("St Louis", 1),
("St Mark", 1)
]
- Now I have a DataFrame specifically prepared for my chart.
- I will be using Pandas' plotting functionality, which internally uses Matplotlib.
- plot.bar()—Creates a bar chart
- x='court'—represents the court
- y="bookings"—represents the booking numbers
- title—sets the chart title.
- grid=True—Displays grid lines.
- rot=45—Rotates the X-axis labels by 45 degrees.
- figsize=(7,3) - 7 inches wide and 3 inches high
- .get_figure() - The Pandas plotting function returns an Axes object.
- Instead of saving the chart to a physical file like chart.png. I will create it as an in-memory binary file, and this is very useful for web applications.
- Save this chart as a 'PNG' into img. So, no chart.png needs to be created on my server.
- seek(0) moves the pointer back to the beginning. This is necessary before reading the image data.

- Now convert the binary image into Base64 text, as HTML can embed a Base64 image directly. Then, it converts the Base64 bytes into a normal Python string.
- This renders dashboard.html and passes all my calculated values into the template.
- Create a Flask-Admin interface for my Flask application, call it JCPC Admin, use this Bootstrap theme, and use my custom dashboard as the home page.
- Bootstrap4Theme tells Flask-Admin to use a Bootstrap 4-based theme and selects the Slate Bootswatch style. This controls things such as the colours and general styling of the admin interface.
<{% extends 'admin/master.html' %}>
<{% block body %}>
<div class="container">
<h2>JCPC Management Dashboard</h2>
<div class="row">
<!-- Left Column: Summary Cards + Chart -->
<div class="col-md-8">
<div class="row">
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5>Total Bookings</h5>
<h2>{{ total_bookings }}</h2>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-body">
<h5>Total Revenue</h5>
<h2>${{ total_revenue }}</h2>
</div>
</div>
</div>
</div>
<!-- Chart inserted directly below Total Bookings and Total Revenue -->
<div class="row mt-4">
<div class="col-md-12">
<div class="card">
<div class="card-body">
<h5>Bookings by Court Chart</h5>
<img
src="data:image/png;base64,{{ chart }}"
class="img-fluid"
alt="Bookings by Court"
>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Text Breakdown & Total Visitors -->
<div class="col-md-4">
<div class="card">
<div class="card-body">
<h5>Bookings by Court</h5>
{% for court, count in bookings_by_court.items() %}
<p style="font-size: x-large;">{{ court }}: {{ count }}</p>
{% endfor %}
</div>
</div>
<!-- Total customers placed directly under Bookings by Court -->
<div class="card mt-4">
<div class="card-body">
<h5>Total Customers</h5>
<h2>{{ total_customers }}</h2>
</div>
</div>
<!-- Repeat Customers placed directly under Total Visitors -->
<div class="card mt-4">
<div class="card-body">
<h5>Repeat Customers</h5>
<h2>{{ repeat_customers }}</h2>
</div>
</div>
</div>
</div>
</div>
<{% endblock %}>1. Extending Flask-Admin's template- This tells Jinja: "Start with Flask-Admin's standard admin/master.html template." Instead of creating a complete HTML document myself, I'm reusing Flask-Admin's layout. This is because Flask-Admin's master.html already handles that.
- My template says, "Put my dashboard content inside the body block," and at the bottom, it closes that block. So, my dashboard becomes the main content of the Flask-Admin page.
- This is a Bootstrap class. It creates a centred container with appropriate horizontal spacing.
- This simply displays the JCPC Management Dashboard, and <h2> is an HTML heading.
- Bootstrap uses a 12-column grid system. Therefore, it indicates that the left side takes approximately 2/3 of the screen and the right side takes approximately 1/3.
- This contains:
- Total Bookings
- Total Revenue
- Bookings by Court chart
- Inside the left column, I create another Bootstrap row. Then, I have two <"col-md-6">, and therefore I will get two cards side by side.
- Both "card" and "card-body" are Bootstrap classes. This means it displays the value from this Python variable.
- For example, if my Flask code shows total_bookings = 25. So the browser will display 25.
- Suppose Python calculates the following: total_revenue = 1250.50. So, Jinja will render $1250.50, and {{ total_revenue }} is simply the dynamic Python value.
- This is one of the more interesting parts. My Python code probably creates a Matplotlib chart and converts it into Base64.
- Then, the template does this: "data: image/png;base64...." So the browser receives the image directly inside the HTML.
- That means I don't need to save the "chart.png" to the static folder.
- This is Bootstrap; it makes the image responsive. In simple terms: Don't let the chart overflow its container. So if the dashboard becomes narrower, the chart can shrink accordingly.
- This is the right-hand side of my dashboard.
- It contains:
- Bookings by Court
- Total Customers
- Repeat Customers
- This is the important Jinja part: "{% for court, count in bookings_by_court.items() %}" Here I am looping through a Python dictionary.
bookings_by_court = {
"st_george": 10,
"st_louis": 7,
"st_mark": 8
}
- While it
.items()will give me the pairs:
court count
--------------------
st_george 10
st_louis 7
st_mark 8
- Therefore, it means the following: for every key/value pair in the dictionary, put the key into count and the value into count.
{% %} versus {{ }}- It means
{{ }}displaying something - While
{% %}perform template logic
- mt-4 is another Bootstrap utility class. "mt" means "margin-top," which means adding some space above this element. That's why my cards don't appear directly against each other.
- If Python has total_customers = 15, the template will display 15
- If Python has repeat_customers = 5, the template will also display 5
Published: August 2026
Last Updated: August 2026
---------------------------------------------------------------------------------------------------------------------------------------------------





Comments