Skip to main content

How to Build a Court Booking System with Flask, TinyDB & HTMX?

How to Build a Court Booking System with Flask, TinyDB & HTMX?

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

Preliminary:
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:
Files and folders

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.

All Python scripts remain the same, except for an amendment to app.py, db.py, and form.py. A new "database.json" was created to store the booking details. 

Meanwhile, for the HTML file, I have created dashboard_1.html to replace the existing 'dashboard.html'. Under the partial folder, I also added 2 files, including 'search.html' and 'search_result.html'.


Step 1: Set up a new database.json
db.py
db = TinyDB("database.json")
bookings_table = db.table("bookings")
Booking = Query()
In the last tutorial, I created users.json to record the registration of a new user. However, for this tutorial, I have created a 'database.json' with a bookings table to record the booking details, such as creating and reading the booking record.


Step 2: Creating a dashboard page and Flask route.
The underlying logic
Creating a dashboard page and Flask route logic
An extracted app.py
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
    )
(i) def dashboard
  • 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_user back to an anonymous user.
  • Otherwise, the logout is aborted.
The interface
Creating a dashboard page and Flask route interface
dashboard_1.html
{% 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.
(i) Navigation menu (nav)
  • 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 the signout route defined in your app.py.
(ii) Title and greeting
  • Displays the application title and indicates that the user is viewing the dashboard. Displays the username of the authenticated user.
(iii) Table
Bootstrap classes used here are
  • Table and table-striped add 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.index is provided by Jinja. Starts numbering from 1.
(iv) Change Booking Icon
  • Displays an edit icon for each booking and passes the bookings doc_id to the change_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.
(v) Entry Pass Icon
  • Displays a document icon for each booking and passes the bookings doc_id to 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.
(vi) Search Button
  • Navigates to the search page.

  • This allows the user to:
    • Search available courts.
    • Create a new booking.
    • Begin another reservation.
(vi) Booking Form Placeholder
  • 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.

Notes: For this tutorial, I will ignore the change_booking and download_pass functions; they will be covered in a future tutorial.


Step 3: Navigate to and configure the Search page and route.
The underlying logic
Navigate to and configure the Search page and route logic
An extracted form.py
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.
(ii) Name Field.

  • 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
(iii) Booking Date

  • Creates a date picker.
  • The selected date is used to search for available court reservations.
  • The validator ensures a booking date is provided.
(iv) Search Button

  • Creates the Search button.
  • When clicked:
    • The form is submitted.
    • Flask checks the selected court and date.
    • Available time slots are displayed.
(v) Back Button

  • Creates a Back button.
  • Typically used to return to the dashboard without performing a search.

An extracted app.py
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
    )
def search()
  • 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 SearchForm class.

    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 form object is passed to Jinja so that each field can be displayed, such as the prefilled name field.
The interface
Navigate to and configure the Search page and route image A
Navigate to and configure the Search page and route image B
partial/search.html
{% 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 %}
(i) Sign Out Link
  • 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.
(ii) Page Heading
  • Displays:

    • The application name.
    • The purpose of the page.

    This tells users they are about to search for available pickleball courts.

(iii) Display Indoor and Outdoor Court Images
  • These images:

    • Help users distinguish between indoor and outdoor courts.
    • Improve the visual appearance of the application.
    • Provide context before selecting a court.
(iv) Search Form
  • 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 results section.
    • The rest of the page remains unchanged.
    • This creates a faster and smoother user experience.
(vi) CSRF Protection
  • 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.

(vii) Court Selection
  • Loops through the RadioField choices.
  • Each iteration creates one radio button.
  • Only one court can be selected.
(viii) Name Field
  • 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.
(ix) Date Field
  • Displays a date picker.
  • The selected date will be used to determine court availability.
(x) Search Button (show in Step 4)
  • When clicked:

    • The selected court.
    • Member's name.
    • Booking date.
            Are submitted to Flask.
  • HTMX then updates only the search results.
(xi) Back Button

  • Returns the user to the dashboard.

  • No search is performed.
(xii) Results Section (show in Step 4)

  • 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.
(xiii) Booking Form Container (show in Step 4)

  • 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.

static/style.css
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 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)


---------------------------------------------------------------------------------------------------------------------------------------------------


Step 4: Selecting the time slot
The underlying logic
Selecting the time slot logic

An extracted app.py
@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 the find_available_slots() function.
  • Renders the search_results.html partial. 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_slot field from each booking.

    The interface
    Selecting the time slot interface
    partial/search_result.html
    {% 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_slots list 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 else block.
    (ii) Display the Heading
    • Displays a heading above the available booking times.
    • The Bootstrap class <mb-3> adds a margin below the heading to improve spacing.
    (iii) Loop Through Every Available Slot
    • Loops through each available time slot returned by Flask.
    (iv) Create a Clickable Time Slot (on step 5) 
    • 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.
    (v) Pass the Selected Booking Details
    • hx-vals sends 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.
    (vi) Specify Where to Display the Response
    • 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.
    (vii) Replace the Existing Content
    • HTMX replaces the content inside the booking-form container.

    • If another time slot is selected later, the previous booking form is replaced with a new one.
    • No page refresh is required.
    (viii) Style the Time Slot
    • Bootstrap classes make each available slot look like a button.

      • btn creates a Bootstrap button.
      • btn-outline-success gives it a green outline.
      • m-2 adds spacing around each button.
    • The result is a row of clickable booking times.
    (ix) Display the Time Slot
    • {{ slot }}: Displays the current time slot.
    • Displays the current time slot.
    (x) Display an Alert Message
    • If available_slots is 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.


    Step 5: Received a Booking
    The underlying logic
    Received a Booking logic

    An extracted form.py
    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")
    (i) Booking Name
    • Stores the member's name. The field is usually prefilled and ensures the field is not empty.
    (ii) Booking Date
    • Stores the selected booking date. The booking date must be provided before the reservation can be saved.

    (iii) Booking Time Slot
    • 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.
    (iv) Court Selection
    • Stores the court selected by the user. and depending on how you store the value. Prevents the booking from being submitted without a court.
    (v) Booking Status
    • 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.
    (vi) Payment Button
    • 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.

    (ix) Back Button
    • 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.

    An extracted app.py
    @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
        )
    Def booking()
    • Creates the /booking route. 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.
    The record is saved in database.json as shown below
    • 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.
    The interface
    Search time-slot interface
    partial/new_booking.html
    <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>
    (i) hx-post
    • Sends a POST request to the Flask booking route, and when the user clicks Payment, HTMX submits the booking form without refreshing the page.
    (ii) hx-target

    • Tells HTMX to replace the contents of the #booking-form container with the server's response.

    • If validation fails, the booking form with validation errors is displayed again.
    • If validation succeeds, the redirect occurs.
    (iii) hx-confirm

    • 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.
    (iv) method="POST"

    • The booking information is sent securely using an HTTP POST request.

    • POST is appropriate because a new booking is being created.
    (v) Form information
    • 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.
    (vi) Payment Button
    • 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.
    (ix) Back Button
    • This button lets the user return to the search page without confirming the booking.
    • hx-get - Sends a GET request to the clear_booking route.
    • 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.
    Final wrap-up:

    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.

    ---------------------------------------------------------------------------------------------------------------------------------------------------

    Thanks for reading! 

    If you haven't subscribed yet, join my newsletter to receive future Python and Flask tutorials.

    ---------------------------------------------------------------------------------------------------------------------------------------------------

    Need a similar system for your business? 
    I build custom Flask web applications and Python automation solutions for SMEs and solopreneurs. 

    ---------------------------------------------------------------------------------------------------------------------------------------------------

    About the Author 
    Kelvin Loh is a Python developer focused on Flask, desktop applications, and business automation solutions. He shares practical tutorials and real-world coding projects to help developers and small businesses build useful applications.

    Comments

    Popular Posts

    How to Build an Audiobook Workflow Desktop System with Python?

    In this tutorial, we will build a simple audiobook player using Python and CustomTkinter. You will learn how to convert text into speech using gTTS and play it with PyGame. We will also implement play, pause, and stop controls, like those in a real audio player. By the end, you will have a clean and functional desktop audiobook app. Prerequisite: This tutorial is part of the standalone tutorial. 📚 View the standalone tutorial Preliminary   Before I begin, it is recommended to activate the virtual environment before installing the relevant dependencies. python -m venv venv venv\Scripts\activate pip install customtkinter pillow gTTS pygame CTkMessagebox pypdf Then, the following steps include setting up the file structure, app.py, and two additional folders: the uploads and media folders. The media folder contains the icons necessary to build the app; there are read, pause, and stop icons, as shown on the diagram. Step 1: Build up the app interface I have 4 sections here: the...

    How to Set Up PgAdmin and Adminer Using Docker Compose?

    If you are new to database management with Docker, this tutorial will guide you through setting up both PgAdmin and Adminer using Docker containers. By containerising these tools, you can quickly launch lightweight and portable database management environments without installing them directly on your operating system. This approach also makes it easier to manage configurations, updates, and multiple projects across different devices.