Booking systems are one of the most practical web applications you'll encounter, whether it's for sports facilities, meeting rooms, or appointment scheduling. In this tutorial, you'll build a complete pickleball court booking system using Flask, TinyDB, Flask-Login, Flask-WTF, Bootstrap, and HTMX. Users will be able to search for available courts, select an available time slot, confirm their reservation, and instantly see their bookings displayed on a personalised dashboard. Along the way, you'll learn how to create a smooth, modern user experience with partial page updates, form validation, and a lightweight NoSQL database—without relying on a full-page refresh.
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
Before I begin, please activate the virtual environment and install the required dependencies.
python -m venv venv
venv\Scripts\activate
pip install flask tinydb flask_wtf
Then, we need to set up the file and folder structure, as below:In this tutorial, I will dive into the details of the process of court booking, from the selection of availability until the booking display on the dashboard.
db = TinyDB("database.json")
bookings_table = db.table("bookings")
Booking = Query()
from flask import Flask, render_template
from tinydb import Query
# Create Dashboard Page
@app.route("/dashboard", methods=["GET"])
@login_required
def dashboard():
# Only fetch the bookings for the current user
bookings = bookings_table.search(
Query().username == current_user.username
)
return render_template(
"dashboard_1.html",
bookings=bookings
)- Requires the user to be logged.
- The main function is to retrieve the data from my database (database.json)—specifically, finding all the court bookings that belong to the logged-in user (Kelvin).
- Passes the list of database records that I have retrieved in the above step directly into the HTML file.
@app.route('/signout', methods=['GET','POST'])
@login_required
def signout():
logout_user()
return redirect(url_for("home")
(ii) def signout- Requires the user to be logged in before signing out.
- If the user confirms, it removes the user's login information from the session. Ends the authenticated session. Sets
current_userback to an anonymous user. - Otherwise, the logout is aborted.
{% extends 'base.html' %}
{% block title %}New Sign In{% endblock %}
{% block content %}
<ul class="nav justify-content-end">
<li><a href="{{ url_for('signout') }}"
onclick="confirmLogout(event)">Sign Out</a>
</li>
</ul>
<div class="container text-center">
<div class="row">
<div class="col-md-12">
<h3>Jersey City Pickleball Club</h3>
<h5>Dashboard</h5>
</div>
<p>Welcome, {{ current_user.username }}!</p>
</div>
<form method="POST" action="{{ url_for('change_booking') }}">
<table class="table table-striped">
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Court</th>
<th>Date</th>
<th>Time Slot</th>
<th>Change</th>
<th>Entry Pass;/</th>
</tr>
</thead>
<tbody>
{% for booking in bookings %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ booking.name }}</td>
<td>{{ booking.court }}</td>
<td>{{ booking.date }}</td>
<td>{{ booking.time_slot }}</td>
<!-- Change Booking Icon -->
<td>
<a href="{{ url_for('change_booking', booking_id=booking.doc_id) }}"
class="btn btn-sm btn-primary">
✏️
</a>
</td>
</tr>
<td>
<a href="{{ url_for('download_pass', booking_id=booking.doc_id) }}"
class="btn btn-sm btn-success"
title="Download Entry Pass">
📄
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Search Button -->
<div class="d-flex justify-content-center" style="gap: 20px;">
<!-- <button href="{{ url_for('search') }}" type="button"
class="btn btn-secondary w-100">Search</button> -->
<a href="{{ url_for('search') }}" class="btn btn-secondary
w-100 d-flex align-items-center justify-content-center"
style="min-width: 120px;">
Search
</a>
</div>
</form>
<div id="booking-form"></div>
{% endblock %}First, apply all the configuration from the base.html, such as flash messages, HTMX, and Bootstrap, by extending from Base.html.- Then, use Bootstrap CSS classes to create an unordered list (<ul>) styled as a navigation menu (
nav) and positioned at the far right of the screen (justify-content-end). Flask/Jinja2 template tag.url_for('signout')dynamically generates the correct URL for thesignoutroute defined in yourapp.py.
- Displays the application title and indicates that the user is viewing the dashboard. Displays the username of the authenticated user.
- Table and
table-stripedadd alternating row colours for readability. - Table heading as follows:
Each column represents:
- # – Row number.
- Name – Member's name.
- Court – Selected pickleball court.
- Date – Booking date.
- Time Slot – Reserved playing time.
- Change – Edit booking action.
- Entry pass – download entry pass
- Loops through every booking passed from the
dashboard()route. Only bookings belonging to the logged-in user are returned. - For each booking, displays the stored values from TinyDB, where the data
loop.indexis provided by Jinja. Starts numbering from 1.
- Displays an edit icon for each booking and passes the bookings
doc_idto thechange_booking()route. The route can then:
- Retrieve the selected booking.
- Prefill the search form.
- Allow the user to check available time slots.
- Update the booking details.
- Displays a document icon for each booking and passes the bookings
doc_idto the download_pass()route. - The route can then:
- Confirms that the booking has been successfully recorded.
- Acts as proof of reservation at the club entrance or reception.
- Allows staff to quickly verify the member's booking details.
- Reduces manual checking and speeds up the check-in process.
- Gives members a convenient document that they can download, print, or save on their mobile device.
Navigates to the search page.
- This allows the user to:
- Search available courts.
- Create a new booking.
- Begin another reservation.
Acts as a container for HTMX. Instead of refreshing the entire page, HTMX can load partial templates into this
<div>, such as the following:- Search results.
- New booking form.
- Update booking form.
This creates a smoother and more responsive user experience.
from flask_wtf import FlaskForm
from wtforms import StringField, DateField, SelectField, RadioField, SubmitField
from wtforms.validators import DataRequired
class SearchForm(FlaskForm):
search_court = RadioField('Search', [validators.DataRequired('Search')],
choices=[
('st_mark', 'St. Mark'),
('st_louis', 'St. Louis'),
('st_joseph', 'St. Joseph'),
('st_george', 'St. George')
])
search_name = StringField('Name:', [validators.DataRequired('Name')])
search_date = DateField('Date:', [validators.DataRequired('Date')])
search = SubmitField("Search")
search_back = SubmitField("Back")
(i) Court Selection.- This field allows the user to choose one court from four available courts.
- Components: RadioField displays a group of radio buttons. Only one option can be selected.
- Ensures the user selects a court before submitting the form. If nothing is selected, validation fails.
- Creates a text box for the member's name.
- The validator ensures the field is not empty.
- Later, this field is automatically prefilled with the username. So the user does not have to type their name again
- Creates a date picker.
- The selected date is used to search for available court reservations.
- The validator ensures a booking date is provided.
- Creates the Search button.
- When clicked:
- The form is submitted.
- Flask checks the selected court and date.
- Available time slots are displayed.
- Creates a Back button.
- Typically used to return to the dashboard without performing a search.
from flask import render_template
from form import SearchForm
from flask_login import login_required
@app.route('/search')
@login_required
def search():
form = SearchForm()
form.search_name.data = current_user.username
return render_template(
"partials/search.html",
form=form
)Ensures only authenticated users can access the search page. If the user has not signed in, Flask-Login redirects them to the login page.
Creates an instance of the
SearchFormclass.At this point, Flask prepares the following:
- Radio buttons
- Name field
- Date picker
- Search button
- Back button
- Automatically fills the Name field with the logged-in (current_user) user's username.
- The
formobject is passed to Jinja so that each field can be displayed, such as the prefilled name field.

{% extends 'base.html' %}
{% block title %}New Sign In{% endblock %}
{% block content %}
<ul class="nav justify-content-end">
<li><a href="{{ url_for('signout') }}"
onclick="confirmLogout(event)">Sign Out</a>
</li>
</ul>
<div class="container text-center">
<div class="row">
<div class="col-md-12">
<h3>Jersey City Pickleball Club</h3>
<h5>Search For Availability</h5>
</div>
<!-- 1. Added d-flex, centered them, and injected a 100px gap between the columns -->
<!-- Changed margin-bottom: auto; to margin-bottom: 100px; -->
<div class="row justify-content-center"
style="column-gap:100px; margin-top:20px;"
class="mb-3">
<!-- <div class="container d-flex flex-column align-items-center
justify-content-center min-vh-50 py-5">
<div class="w-100" style="max-width: 500px;"> -->
<!-- Indoor Courts-->
<div class="col-md-5">
<div class="card h-60 shadow-sm">
<img
src="{{ url_for('static', filename='assets/indoor.jpg') }}"
class="img-fluid w-100"
alt="Professional Courts"
style="object-fit: cover;">
<h5 class="m-0 p-0 text-center">Indoor Courts</h5>
<p class="court-subtitle mt-0 pt-0 mb-0 text-center">St.
Mark and St. Louis Courts</p>
</div>
</div>
<div class="col-md-5">
<div class="card h-60 shadow-sm">
<img
src="{{ url_for('static', filename='assets/outdoor.jpg') }}"
class="img-fluid w-100"
alt="Professional Courts"
style="object-fit: cover;">
<h5 class="m-0 p-0 text-center">Outdoor Courts</h5>
<p class="court-subtitle mt-0 pt-0 mb-0 text-center">St. Joseph
and St. George Courts</p>
</div>
</div>
<!-- <form method="POST" action=""> -->
<form
hx-post="{{ url_for('search_results') }}"
hx-target="#results"
hx-swap="innerHTML">
{{ form.hidden_tag() }}
<!-- Court Selection (2x2 Radio Grid) -->
<div class="row g-3 mb-4">
{% for subfield in form.search_court %}
<div class="col-6">
<label class="form-check border rounded bg-light p-3 d-flex
align-items-center m-0" style="cursor: pointer;">
{{ subfield(class="form-check-input me-3") }}
<span class="form-check-label text-dark
fw-bold">{{ subfield.label.text }}</span>
</label>
</div>
{% endfor %}
</div>
<!-- Name Input -->
<div class="row mb-3 text-start align-items-center">
<div class="col-sm-3">
{{ form.search_name.label(class="form-label mb-0 fs-5
fw-bold text-dark") }}
</div>
<div class="col-9">
{{ form.search_name(class="form-control form-control-lg") }}
</div>
</div>
<!-- Date Field -->
<div class="row mb-3 text-start align-items-center">
<div class="col-sm-3">
{{ form.search_date.label(class="form-label mb-0 fs-5
fw-bold text-dark") }}
</div>
<div class="col-9">
{{ form.search_date(class="form-control form-control-l") }}
</div>
</div><br>
<!-- Centred, Equal-Sized Action Buttons with a 20px gap -->
<!-- 2 buttons include the search and back button-->
<div class="row">
<div class="col-6">
{{ form.search(class="btn btn-success w-100") }}
</div>
<div class="col-6">
<!-- Navigation Back Button styled exactly the same -->
<a href="{{ url_for('dashboard') }}" class="btn btn-secondary
w-100 d-flex align-items-center justify-content-center"
style="min-width: 120px;">
Back
</a>
</div>
</div>
</form>
<!-- Results Section Section -->
<div class="text-center my-4">
<p class="text-center text-muted">Search Results will appear here</p>
<!-- <h5 class="text-center text-muted mt-4">Search Results will appear
here</h5> -->
<div class="bg-white text-success fw-bold py-3 px-4 border rounded
shadow-sm fs-4
d-inline-block w-100">
<div id="results"></div>
<div id="booking-form"></div>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-center">
<footer class="footer">
<div class="footer-content text-body-secondary small">
<a href="{{ url_for('privacy') }}" class="text-decoration-none
text-body-secondary">Privacy</a>
<span>|</span>
<a href="{{ url_for('terms_conditions') }}"
class="text-decoration-none
text-body-secondary">Terms & Condition</a>
<span>|</span>
<span>NJPC.right reserved @ 2026</span>
</div>
</footer>
</div>
{% endblock %}- Generates the URL for the
signout()route. - Calls the JavaScript confirmation dialogue before signing out.
-
If confirmed:
-
Flask executes
logout_user(). - The user is redirected to the home page.
-
Flask executes
Displays:
- The application name.
- The purpose of the page.
This tells users they are about to search for available pickleball courts.
These images:
- Help users distinguish between indoor and outdoor courts.
- Improve the visual appearance of the application.
- Provide context before selecting a court.
- hx-post: Instead of performing a normal form submission, HTMX sends the form data asynchronously to s without refreshing the entire page.
- hx-target: Tells HTMX to place the returned HTML inside the result section.
- hx-swap:
- Replaces only the content inside the
resultssection. - The rest of the page remains unchanged.
- This creates a faster and smoother user experience.
Automatically inserts hidden fields required by Flask-WTF.
These include:
- CSRF token
- Other hidden form data
This protects the application against Cross-Site Request Forgery (CSRF) attacks.
- Loops through the RadioField choices.
- Each iteration creates one radio button.
- Only one court can be selected.
- Displays the member's name.
- This field is automatically prefilled in the Flask route. So the user does not need to enter their name manually.
- Displays a date picker.
- The selected date will be used to determine court availability.
When clicked:
- The selected court.
- Member's name.
- Booking date.
- HTMX then updates only the search results.
Returns the user to the dashboard.
- No search is performed.
- Initially empty.
- After clicking Search, Flask returns a partial template to show the availability of time slots or no available time slots.
- HTMX inserts the response here automatically.
Initially empty.
- When the user clicks an available time slot, HTMX loads another partial template into this container.
- Again, no full-page refresh is required.
ul {
list-style-type: none;
padding-right: 50px;
padding-top: 20px;
}
h5 {
margin-top: 50px;
}
p {
margin-top: 50px;
font-size: 20px;
font-weight: bold;
}
/* Adds the 100px gap between the stacked column cards */
.row .col-md-5 {
margin-bottom: 100px;
}
🎁 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)
@app.route("/search-results", methods=["POST"])
def search_results():
"""
Route handler for processing court availability searches.
Triggered via an HTMX POST request from the frontend form.
"""
# Instantiate the SearchForm to validate or pass down to sub-components
# if needed
form = SearchForm()
# Query the database for available time slots based on the user's court
# selection and chosen date
available_slots = find_available_slots(
court=request.form["search_court"],
booking_date=request.form["search_date"]
)
# Render and return only the HTML snippet (partial) for the results section.
# HTMX swaps this snippet into the `#results` div without reloading
# the whole page.
return render_template(
"partials/search_results.html",
available_slots=available_slots,
form=form
)def search_results()- Accepts POST requests because the user submits the search form. This route is called by HTMX after the user clicks the Search button, and it checks which court and date the user selected.
- Creates an instance of
SearchForm. Allows Flask-WTF to validate and access the submitted form data. Makes the form available if it needs to be rendered again. request.form - retrieves the user-submitted values, including the court and time, and these values are passed to thefind_available_slots()function.- Renders the
search_results.htmlpartial. Passes the list of available time slots and the form object. Because HTMX is used, only the Results section of the page is updated instead of refreshing the entire page.
def find_available_slots(court, booking_date):
"""
Compares all potential daily club slots against slots that are already booked
to determine which time windows are still open for reservation.
"""
# Define the complete operating schedule for the pickleball courts
all_slots = [
"9:00 AM - 10:00 AM",
"10:00 AM - 11:00 AM",
"11:00 AM - 12:00 PM",
"12:00 PM - 13:00 PM",
"13:00 PM - 14:00 PM",
'15:00 PM - 16:00 PM',
'16:00 PM - 17:00 PM',
'17:00 PM - 18:00 PM'
]
# Retrieve a list of time slots that are already reserved for this
# specific court/date
booked_slots = get_booked_slots(
court, booking_date
)
# Filter out the reserved slots using a list comprehension, leaving
# only the free ones
available = [
slot for slot in all_slots
if slot not in booked_slots
]
return available
def find_available_slot()- Define All Possible Time Slots: A list represents every time slot available during the day, and these are the booking times offered by the club.
- Retrieve Already Booked Slots. By calling another helper function, which is get_booked_slots()
- Its purpose is to retrieve all booked time slots for:
- the selected court
- the selected date
- Calculate Available Slots: This is a Python list comprehension.
- It compares:
- all possible slots
- already booked slots
- Only the unbooked slots are returned.
def get_booked_slots(court, booking_date):
"""
Queries the database table for existing reservation records
matching the specified court and date criteria.
"""
# Search the 'bookings_table' for rows matching both the chosen
# court and date
bookings = bookings_table.search(
(Booking.court_selection == court) &
(Booking.date == booking_date)
)
# Extract just the 'time_slot' string from each matching booking
# record and return the list
return [booking["time_slot"] for booking in bookings]def get_booked_slots(court, booking_date)This helper function retrieves existing bookings from TinyDB.
- It receives:
- selected court
- selected booking date
Searches TinyDB for bookings that satisfy both conditions:
- same court
- same booking date
- Only bookings matching both values are returned.
- This list comprehension extracts only the
time_slotfield from each booking.
{% if available_slots %}
<h5 class="mb-3">Available Time Slots</h5>
{% for slot in available_slots %}
<a
hx-get="{{ url_for('new_booking') }}"
hx-vals='{
"name":"{{ form.search_name.data }}",
"court":"{{ form.search_court.data }}",
"date":"{{ form.search_date.data }}",
"time":"{{ slot }}"
}'
hx-target="#booking-form"
hx-swap="innerHTML"
class="btn btn-outline-success m-2">
{{ slot }}
</a>
{% endfor %}
{% else %}
<div class="alert alert-danger">
No available time slots.
</div>
{% endif %}
(i) Check Whether Time Slots Are Available- Checks whether the
available_slotslist contains any items. - If the list is not empty, Jinja executes the first block. So the available slots are displayed.
-
If the list is empty, Jinja skips to the
elseblock.
- Displays a heading above the available booking times.
- The Bootstrap class <mb-3> adds a margin below the heading to improve spacing.
- Loops through each available time slot returned by Flask.
- Instead of navigating to another page, clicking the link sends an HTMX GET request to /new_booking
- The
new_booking()route prepares the booking form with the selected details.
hx-valssends additional values along with the HTMX request.These values include:
- Member name
- Selected court
- Selected booking date
- Selected time slot
- These values are received by the
new_booking()route and used to prefill the booking form.
- The HTML returned by
new_booking()is inserted into: "booking-form" - The search results remain visible while only the booking form is added to the page.
HTMX replaces the content inside the
booking-formcontainer.- If another time slot is selected later, the previous booking form is replaced with a new one.
- No page refresh is required.
Bootstrap classes make each available slot look like a button.
btncreates a Bootstrap button.btn-outline-successgives it a green outline.m-2adds spacing around each button.- The result is a row of clickable booking times.
- {{ slot }}: Displays the current time slot.
- Displays the current time slot.
- If
available_slotsis empty, this section is displayed instead. Bootstrap displays a red alert box informing the user that no courts are available for the selected date and court.
- Instead of showing empty space, the user immediately knows that they need to choose another date or court.
class BookingForm(FlaskForm):
booking_name = StringField('Name',
[validators.DataRequired(message="Name is required.")])
booking_date = StringField('Date',
[validators.DataRequired(message="Date is required.")])
booking_time_slot = StringField('Time Slot',
[validators.DataRequired(message="Time Slot is required.")])
booking_court_selection = StringField('Court Selection',
[validators.DataRequired(message="Court Selection is required.")])
booking_status = StringField('Booking Status',
[validators.DataRequired(message="Booking Status is required.")])
booking_payment = SubmitField("Payment")
booking_back = SubmitField("Back") - Stores the member's name. The field is usually prefilled and ensures the field is not empty.
- Stores the selected booking date. The booking date must be provided before the reservation can be saved.
- Stores the selected time slot. This value comes from the time slot button selected on the search results page and ensures a time slot has been selected.
- Stores the court selected by the user. and depending on how you store the value. Prevents the booking from being submitted without a court.
Stores the current status of the booking.
Typical values include:
- Accepted
- Pending
- Cancelled
- Completed
- Since the payment is not refundable, cancellation may not be relevant.
Creates the Payment button.
When clicked:
- The booking form is submitted.
- Flask validates all fields.
- The booking is saved to TinyDB.
- The user is redirected to the dashboard.
Although the button is labelled Payment, my current tutorial uses it to confirm the booking rather than integrating with Stripe and I will cover it next tutorial.
Its purpose is to return the user to the search page without confirming the booking.
In my application, this button is typically handled using HTMX to reload the search interface rather than submitting the form.
@app.route("/booking", methods=["POST"])
@login_required
def booking():
form = BookingForm()
if form.validate_on_submit():
bookings_table.insert({
"username": current_user.username,
"name": form.booking_name.data,
"court": form.booking_court_selection.data,
"date": form.booking_date.data,
"time_slot": form.booking_time_slot.data,
"status": "Accepted"
})
flash(
"Booking accepted successfully!",
"success"
)
response = make_response("")
response.headers["HX-Redirect"] = url_for("dashboard")
return response
print(form.errors) # debugging
return render_template(
"partials/new_booking.html",
form=form
)- Creates the
/bookingroute. Accepts only POST requests because the user is submitting a booking form. The route is triggered when the Payment (or Confirm Booking) button is clicked. - Ensures that only authenticated users can make a booking. If a user is not logged in, Flask-Login redirects them to the sign-in page. This prevents anonymous users from creating reservations.
- Creates an instance of the
BookingForm. - Retrieves all submitted booking information. Gives Flask-WTF access to name, court, date, and time slot.
- This performs two checks: the request method is POST, and all form fields satisfy the defined validation rules.
- If validation succeeds, Flask continues with the booking process.
- If validation fails, the booking form is displayed again with validation errors.
- Adds a new document to the TinyDB
bookings_table. - Stores the username of the currently logged-in member, and this allows the dashboard to later retrieve only that user's bookings.
- Stores the member's full name from the booking form.
- Stores the selected court.
- Stores the booking date selected by the user
- Stores the reserved time slot.
- Adds a booking status. Initially, every confirmed booking is marked as accepted, and this field makes the application easier to extend later.
- Displays a Bootstrap success alert. The
"success"category allows Bootstrap to style the message as a green success notification. - Creates an empty HTTP response. Normally, Flask would return an HTML page. Instead, this response is used to send an HTMX instruction. This is an HTMX-specific response header. Instead of refreshing the page, HTMX reads this header and redirects the browser to the dashboard, and the dashboard is then refreshed and shows the newly created booking.
- If validation does not succeed, Flask renders the booking form again. The user can then correct the errors and resubmit the booking.
@app.route("/clear-booking")
@login_required
def clear_booking():
return ""
def clear_booking()- This route is called when the Back button is clicked.
- Ensures only authenticated members can access this route. Prevents unauthorised users from interacting with the booking process.
- Its only responsibility is to remove the booking confirmation form.
- An empty string is returned to HTMX, and HTMX replaces the contents of booking-form with the empty response.
- As a result:
- The booking confirmation form disappears.
- The search results remain on the screen.
- The user can immediately select another available time slot.
- No page refresh is required.
<form
hx-post="{{ url_for('booking') }}"
hx-target="#booking-form"
hx-swap="innerHTML"
hx-confirm="Confirm this booking and proceed to payment? Once confirmed,
the booking cannot be changed or cancelled."
method="POST">
{{ form.hidden_tag() }}
<!-- Name Field -->
<div class="row mb-3 align-items-center">
<div class="col-sm-3">
{{ form.booking_name.label(class="form-label mb-0 fs-5
fw-bold text-dark") }}
</div>
<div class="col-sm-9">
{{ form.booking_name(class="form-control", readonly=True) }}
</div>
</div>
<!-- Date Field -->
<div class="row mb-3 align-items-center">
<div class="col-sm-3">
{{ form.booking_date.label(class="form-label mb-0 fs-5
fw-bold text-dark") }}
</div>
<div class="col-sm-9">
{{ form.booking_date(class="form-control", readonly=True) }}
</div>
</div>
<!-- Time Slot Field -->
<div class="row mb-3 align-items-center">
<div class="col-sm-3">
{{ form.booking_time_slot.label(class="form-label mb-0 fs-5
fw-bold text-dark") }}
</div>
<div class="col-sm-9">
{{ form.booking_time_slot(class="form-control", readonly=True) }}
</div>
</div>
<!-- Court Selection Field -->
<div class="row mb-3 align-items-center">
<div class="col-sm-3">
{{ form.booking_court_selection.label(class="form-label mb-0 fs-5
fw-bold text-dark") }}
</div>
<div class="col-sm-9">
<!-- Added text-dark here -->
{{ form.booking_court_selection(class="form-select form-select-lg",
readonly=True) }}
</div>
</div>
<!-- Submit Button -->
<div class="row">
<div class="col-6">
{{ form.booking_payment(class="btn btn-success w-100") }}
</div>
<div class="col-6">
<button
class="btn btn-secondary w-100"
hx-get="{{ url_for('clear_booking') }}"
hx-target="#booking-form"
hx-swap="innerHTML">
Back
</button>
</div>
</div>
</form>- Sends a POST request to the Flask booking route, and when the user clicks Payment, HTMX submits the booking form without refreshing the page.
Tells HTMX to replace the contents of the
#booking-formcontainer with the server's response.- If validation fails, the booking form with validation errors is displayed again.
- If validation succeeds, the redirect occurs.
- Displays a confirmation dialogue before the form is submitted.
- The user sees:
- OK → Submit the booking.
- Cancel → Abort the submission and remain on the booking form.
The booking information is sent securely using an HTTP POST request.
- POST is appropriate because a new booking is being created.
- The form details are:
- CSRF Protection - Automatically inserts hidden fields generated by Flask-WTF. This protects the application against cross-site request forgery attacks.
- Member Name - Displays the member's name, and the name is prefilled from the search page or the logged-in user
- Booking Date - Displays the selected booking date.
- Time Slot - Displays the selected available time slot.
- Court Selection - Displays the selected court.
- The user can view it; however, the user cannot modify it.
When clicked:
-
HTMX displays the confirmation dialog (
hx-confirm). -
If the user confirms, the form is submitted to
/booking. - Flask validates the form.
- TinyDB saves the booking.
- A success message is flashed.
- HTMX redirects the user to the dashboard.
-
HTMX displays the confirmation dialog (
- This button lets the user return to the search page without confirming the booking.
- hx-get - Sends a GET request to the
clear_bookingroute. - hx-target - Updates only the booking form section.
- hx-swap - Clears or replaces the booking form content, and the user can then choose another court or time slot.
In this tutorial, you learned how to build a complete pickleball court booking system using Flask, Flask-Login, Flask-WTF, TinyDB, and HTMX. Users can securely sign in, search for available courts and time slots, confirm their bookings, and view their reservations on a personalised dashboard without unnecessary page refreshes. Along the way, you implemented form validation, database operations, partial page updates, flash messages, and user authentication to create a smooth booking experience. By combining these technologies, you have built a modern, responsive web application that demonstrates practical full-stack Flask development. This project also provides a solid foundation for future enhancements, including online payments, email notifications, QR code entry passes, and administrative features.
---------------------------------------------------------------------------------------------------------------------------------------------------
.png)










Comments