Build a Helpdesk App with Python Flask

Build a multi-user helpdesk ticketing system with login and role-based access.

Introduction

30 Second Summary

Every time you contact customer support and get a ticket number, there is a system behind the scenes tracking your issue, routing it to the right person, and keeping a record of every response.

In this project, you will build your own helpdesk ticketing system using Python and Flask. Customers submit support tickets, agents respond and update their status, and everything is stored in a local SQLite database.

What You'll Build

When you run your finished app and open your browser, you will see a login page where customers and agents each get their own tailored view of the system, submit and respond to tickets, and watch status updates happen in real time.

By the end of this project, you'll have:

  • A ticket submission system where customers register, log in, and describe their issue with a subject and description.
  • A role-based dashboard where agents see all open tickets from every customer, update ticket status, and post responses.
  • Secure user authentication with password hashing so each person only sees the tickets and actions they are allowed to.
  • Secret Mission: Add a ticket priority system with color-coded urgency levels and filtering on the agent dashboard.

Are there any prerequisites?

None. This guide walks you through installing Python and every other tool from scratch. All you need is a computer with VS Code installed.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters to you.

Install Python and Set Up Your Project

Every web application needs a programming language to run on. Python is one of the most popular languages for building web apps because of its readable syntax and huge library ecosystem.

In this step, you will install Python, create a project folder, and set up Flask (a lightweight web framework) so you can run a minimal app in your browser.

In this step, get ready to:
  • Install Python 3.13 on your machine.
  • Create a project folder with a virtual environment and install Flask.
  • Create and run a minimal Flask app that displays a message in your browser.
Install Python 3.13

Python is the language your Flask web app runs on. You need version 3.13 or higher installed on your machine before anything else will work.

  • Check whether Python is already installed by running the version check command for your operating system:

macOS

python3 --version

Windows

python --version

✔️ I see version 3.13 or higher

You are all set. Move on to the next substep below.

ⓧ I see an older version

You have Python installed, but the version is too old for this project. You need 3.13 or higher.

  • Go to python.org/downloads and download the Python 3.13 installer for your operating system.
  • On Windows, run the installer and check the Add Python to PATH checkbox on the very first page before clicking Install Now.
  • On macOS, open the downloaded .pkg file and follow the installer prompts through to completion.
  • Close and reopen your terminal, then re-run the version check command above to confirm it now shows 3.13 or higher.

macOS shows an older version after installing

macOS may still resolve python3 to the system default. Try running python3.13 --version instead. If that works, use python3.13 in place of python3 for the rest of this project.

ⓧ Command not found

Python is not installed yet. Follow these steps to install it.

  • Go to python.org/downloads and download the Python 3.13 installer for your operating system.
  • On Windows, run the installer and check the Add Python to PATH checkbox on the very first page before clicking Install Now.
  • On macOS, open the downloaded .pkg file and follow the installer prompts through to completion.
  • Close your terminal completely and open a new one so the PATH updates take effect.
  • Run the version check command again to confirm you see 3.13 or higher.

Still seeing command not found?

On Windows, the most common cause is forgetting to check Add Python to PATH during installation. Re-run the installer and select Modify to enable the PATH option.

On macOS, try typing python3.13 --version to see if the installer placed the binary under its versioned name.

help me fix Python not being found after installation

Create the project folder and install Flask

A virtual environment keeps your project's packages isolated from the rest of your system. This prevents conflicts between different projects that might use different library versions.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir helpdesk
cd helpdesk

Your terminal is now inside the helpdesk folder on your Desktop.

  • Create a virtual environment and activate it by running these commands:

macOS

python3 -m venv venv
source venv/bin/activate

Windows

python -m venv venv
venv\Scripts\activate

You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

On Windows, PowerShell may block scripts by default. Run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned and then try venv\Scripts\activate again.

On macOS, make sure you typed source venv/bin/activate (with the word source at the start).

help me activate my Python virtual environment

  • Install Flask by running:
pip install Flask==3.1.3

You will see output showing Flask and its dependencies being downloaded and installed. The final line confirms everything installed successfully.

What is Flask?

Flask is a lightweight Python web framework. It handles the plumbing of receiving web requests and sending back responses so you can focus on building your app's features.

The ==3.1.3 part pins the exact version so everyone following this project gets the same behavior.

Create and run a minimal Flask app

With Flask installed, you can now write a tiny web app that proves everything is wired up correctly.

  • In VS Code, open the helpdesk folder on your Desktop by clicking File in the top menu bar, then Open Folder, and selecting the helpdesk folder.
  • Create a new file called app.py inside the helpdesk folder by clicking the new file icon in the Explorer sidebar.
  • Paste the following code into app.py:
import sqlite3
from flask import Flask, render_template, request, redirect, url_for, session, flash, g
from werkzeug.security import generate_password_hash, check_password_hash

# Create the Flask application
app = Flask(__name__)
app.secret_key = 'helpdesk-secret-key-change-in-production'

# Define the homepage route
@app.route('/')
def index():
    return 'HelpDesk is running!'

# Run the app when this file is executed directly
if __name__ == '__main__':
    app.run(debug=True)

What does this code do?

  • The import lines load libraries you will use throughout the project. Flask is the web framework, sqlite3 handles the database, and werkzeug.security provides password hashing.
  • app = Flask(__name__) creates the application object that Flask uses to handle incoming requests.
  • app.secret_key sets a key Flask uses to sign session cookies. You will use sessions to track logged-in users later.
  • @app.route('/') tells Flask to run the index() function whenever someone visits the homepage.
  • app.run(debug=True) starts a local development server that auto-reloads when you change your code.
  • Save app.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Before you run the app, what do you think will happen when you visit http://127.0.0.1:5000 in your browser?

  • Run the app by executing this command in your terminal (make sure you still see (venv) in the prompt):

macOS

python3 app.py

Windows

python app.py
  • Open your browser and go to http://127.0.0.1:5000.

You should see the text "HelpDesk is running!" displayed on the page. This confirms Python, Flask, and your project structure are all working correctly.

What is 127.0.0.1:5000?

127.0.0.1 is your computer's local address (also called localhost). Port 5000 is where Flask's development server listens by default. Together they let you access your app without deploying it to the internet.

Don't see the message in your browser?

Check that your terminal is still running the Flask server. You should see output like Running on http://127.0.0.1:5000. If the command exited with an error, look for a typo in app.py.

Make sure your virtual environment is active ((venv) in the prompt). If it is not, activate it again and re-run the command.

help me get my Flask app running

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py and that your browser shows "HelpDesk is running!" at http://127.0.0.1:5000.

ⓧ I'd like to double check the full code

Here is the complete app.py file you should have at this point:

import sqlite3
from flask import Flask, render_template, request, redirect, url_for, session, flash, g
from werkzeug.security import generate_password_hash, check_password_hash

app = Flask(__name__)
app.secret_key = 'helpdesk-secret-key-change-in-production'

@app.route('/')
def index():
    return 'HelpDesk is running!'

if __name__ == '__main__':
    app.run(debug=True)

Your development environment is ready and Flask is serving your first page. Next up, you will build the ticket submission system so users can create and view support tickets.

Build the Ticket Submission System

Your Flask app is running and showing a plain text message. That proves the framework works, but a helpdesk needs more than a greeting.

The core of any helpdesk is the ability to submit tickets and see them listed. In this step, you will add a SQLite database to store tickets, build an HTML homepage that lists them, and create a form for submitting new ones.

In this step, get ready to:
  • Set up a SQLite database with a tickets table.
  • Create a homepage that displays all tickets from the database.
  • Build a ticket submission form that saves tickets and redirects to the homepage.
Add database helpers and routes to your app

Flask does not include a database out of the box, but Python ships with sqlite3 built in. You will add helper functions that open a database connection, create the tickets table on first run, and close the connection when each request finishes.

  • In VS Code, open app.py from the Explorer sidebar.
  • Find the line app.secret_key = 'helpdesk-secret-key-change-in-production'.
  • Delete everything below that line (the old @app.route('/') function and the if __name__ block).
  • Paste the following database helpers below the secret_key line:
DATABASE = 'helpdesk.db'

def get_db():
    # Reuse the database connection within a single request
    if 'db' not in g:
        g.db = sqlite3.connect(DATABASE)
        g.db.row_factory = sqlite3.Row
    return g.db

@app.teardown_appcontext
def close_db(exception):
    # Close the connection when the request ends
    db = g.pop('db', None)
    if db is not None:
        db.close()

def init_db():
    # Create the tickets table if it doesn't exist yet
    db = get_db()
    db.execute('''CREATE TABLE IF NOT EXISTS tickets (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        subject TEXT NOT NULL,
        description TEXT NOT NULL,
        status TEXT NOT NULL DEFAULT 'Open',
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
    )''')
    db.commit()

def get_current_user():
    # Placeholder for user authentication (added later)
    return None

What does this code do?

  • DATABASE = 'helpdesk.db' sets the filename for your SQLite database. It will be created automatically in your project folder.
  • get_db() opens a connection to the database and stores it on Flask's g object so it can be reused within one request.
  • close_db() runs automatically after each request finishes, closing the database connection.
  • init_db() creates the tickets table with columns for id, subject, description, status (defaulting to "Open"), and a timestamp.
  • get_current_user() is a placeholder that returns None for now. You will replace it with real user lookup in Step 3.
  • Now paste the route functions and the updated main block directly below the code you just added:
@app.route('/')
def index():
    # Query all tickets and display them on the homepage
    db = get_db()
    tickets = db.execute('SELECT * FROM tickets ORDER BY created_at DESC').fetchall()
    return render_template('index.html', tickets=tickets, user=None)

@app.route('/submit', methods=['GET', 'POST'])
def submit_ticket():
    # Handle the ticket submission form
    if request.method == 'POST':
        subject = request.form['subject']
        description = request.form['description']
        if not subject or not description:
            flash('Subject and description are required.')
        else:
            db = get_db()
            db.execute('INSERT INTO tickets (subject, description) VALUES (?, ?)', (subject, description))
            db.commit()
            flash('Ticket submitted successfully!')
            return redirect(url_for('index'))
    return render_template('submit.html', user=None)

if __name__ == '__main__':
    with app.app_context():
        init_db()
    app.run(debug=True)

What do these routes do?

  • The / route queries every ticket from the database (newest first) and passes them to an HTML template called index.html.
  • The /submit route handles both GET (show the form) and POST (save the ticket). On POST, it reads the form fields, inserts a row into the database, and redirects back to the homepage.
  • The if __name__ block now calls init_db() inside an app context before starting the server. This ensures the tickets table exists on first run.
  • Save app.py.

Seeing red squiggly lines in VS Code?

If VS Code shows import errors, make sure your virtual environment is selected as the Python interpreter. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), type Python: Select Interpreter, and choose the one inside your venv folder.

help me fix import errors in my Flask app

Create the HTML templates

Flask uses Jinja templates to generate HTML pages. You will create a base template (shared layout with navigation and styles) and a page template for the homepage.

  • Create a templates folder inside your helpdesk folder by running this command in your terminal:
mkdir templates
  • Create a new file called base.html inside the templates folder. In VS Code, right-click the templates folder in the Explorer sidebar and select New File.
  • Name the file base.html and paste the following head section:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>HelpDesk - {% block title %}Home{% endblock %}</title>
    <style>
        body { font-family: Arial, sans-serif; max-width: 800px; margin: 0 auto; padding: 20px; background: #f5f5f5; }
        nav { background: #2c3e50; padding: 10px 20px; margin-bottom: 20px; border-radius: 5px; }
        nav a { color: white; text-decoration: none; margin-right: 15px; }
        nav a:hover { text-decoration: underline; }
        .container { background: white; padding: 20px; border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
        h1, h2 { color: #2c3e50; }
        form { margin: 20px 0; }
        label { display: block; margin-top: 10px; font-weight: bold; }
        input[type="text"], input[type="password"], textarea, select { width: 100%; padding: 8px; margin-top: 5px; border: 1px solid #ddd; border-radius: 3px; box-sizing: border-box; }
        textarea { height: 100px; }
        button, .btn { background: #3498db; color: white; padding: 10px 20px; border: none; border-radius: 3px; cursor: pointer; text-decoration: none; display: inline-block; }
        button:hover, .btn:hover { background: #2980b9; }
        .flash { background: #f39c12; color: white; padding: 10px; border-radius: 3px; margin-bottom: 15px; }
        table { width: 100%; border-collapse: collapse; margin-top: 15px; }
        th, td { padding: 10px; text-align: left; border-bottom: 1px solid #ddd; }
        th { background: #ecf0f1; }
        .status { padding: 3px 8px; border-radius: 3px; font-size: 0.85em; }
        .status-open { background: #3498db; color: white; }
        .status-in-progress { background: #f39c12; color: white; }
        .status-closed { background: #27ae60; color: white; }
        .response { background: #ecf0f1; padding: 10px; margin: 10px 0; border-radius: 3px; border-left: 3px solid #3498db; }
        .response-meta { font-size: 0.85em; color: #7f8c8d; }
    </style>
</head>

What is a base template?

A base template defines the shared HTML structure (head, styles, navigation) that every page inherits. Individual pages extend it and fill in only the content that changes. This avoids duplicating the same HTML on every page.

The CSS classes like .status-open and .status-closed define color-coded badges you will see on tickets later.

  • Paste the following body section directly below the </head> line you just added, still in the same base.html file:
<body>
    <nav>
        {% if user %}
            <a href="{{ url_for('index') }}">Home</a>
            {% if user.role == 'customer' %}
                <a href="{{ url_for('submit_ticket') }}">New Ticket</a>
            {% endif %}
            {% if user.role == 'agent' %}
                <a href="{{ url_for('agent_dashboard') }}">Agent Dashboard</a>
            {% endif %}
            <a href="{{ url_for('logout') }}">Logout ({{ user.username }})</a>
        {% else %}
            <a href="{{ url_for('login') }}">Login</a>
            <a href="{{ url_for('register') }}">Register</a>
        {% endif %}
    </nav>
    <div class="container">
        {% with messages = get_flashed_messages() %}
            {% if messages %}
                {% for message in messages %}
                    <div class="flash">{{ message }}</div>
                {% endfor %}
            {% endif %}
        {% endwith %}
        {% block content %}{% endblock %}
    </div>
</body>
</html>

Why does the nav reference routes that don't exist yet?

The navigation template includes links to login, register, and agent_dashboard routes that you will build in Steps 3 and 4. Flask needs these endpoints to exist before the template can render. You will add placeholder routes in a moment to make the page work now.

The {% block content %}{% endblock %} tag marks where child templates inject their page-specific HTML.

  • Save base.html.
  • Create another new file in the templates folder called index.html and paste the following:
{% extends "base.html" %}
{% block title %}My Tickets{% endblock %}
{% block content %}
<h1>My Tickets</h1>
<a href="{{ url_for('submit_ticket') }}" class="btn">Submit New Ticket</a>
{% if tickets %}
<table>
    <tr>
        <th>Subject</th>
        <th>Status</th>
        <th>Created</th>
    </tr>
    {% for ticket in tickets %}
    <tr>
        <td>{{ ticket.subject }}</td>
        <td><span class="status status-{{ ticket.status|lower|replace(' ', '-') }}">{{ ticket.status }}</span></td>
        <td>{{ ticket.created_at }}</td>
    </tr>
    {% endfor %}
</table>
{% else %}
<p style="margin-top: 15px;">You have no tickets yet. Submit one above!</p>
{% endif %}
{% endblock %}

What does this template do?

This template extends base.html to inherit the styles and navigation. It loops through the tickets list passed from the route and renders each one as a table row. If there are no tickets, it shows a friendly prompt to submit one.

  • Save index.html.
Create the ticket submission form

The homepage can display tickets, but you need a way to create them. You will add a submission form with subject and description fields.

  • Create a new file in the templates folder called submit.html and paste the following:
{% extends "base.html" %}
{% block title %}Submit Ticket{% endblock %}
{% block content %}
<h1>Submit a Ticket</h1>
<form method="POST">
    <label for="subject">Subject:</label>
    <input type="text" id="subject" name="subject" required>
    <label for="description">Description:</label>
    <textarea id="description" name="description" required></textarea>
    <button type="submit" style="margin-top: 15px;">Submit Ticket</button>
</form>
{% endblock %}

What does this form do?

When a user fills in the subject and description and clicks Submit Ticket, the form sends a POST request to the /submit route. Your submit_ticket function reads those values, inserts a new row in the database, and redirects to the homepage.

  • Save submit.html.

The base template's navigation calls url_for('login') and url_for('register'), but those routes do not exist yet. Flask raises an error if a template references a missing endpoint. You need to add two temporary placeholder routes so the page can render.

  • Switch back to app.py. Find the if __name__ == '__main__': line and paste the following placeholder routes directly above it:
@app.route('/login')
def login():
    # Placeholder - full login built in Step 3
    return redirect(url_for('index'))

@app.route('/register')
def register():
    # Placeholder - full registration built in Step 3
    return redirect(url_for('index'))

Why add placeholders?

Flask's template engine evaluates every url_for() call at render time. If an endpoint does not exist, the page fails to load entirely. These two stubs simply redirect back to the homepage. You will replace them with full login and registration in Step 3.

  • Save app.py.
Run the app and submit test tickets

Everything is wired up. Time to see it work.

  • If the Flask server is still running from Step 1, stop it by pressing Ctrl+C in the terminal.
  • Start the server again by running:
python app.py

Before you open the browser, what do you expect to see on the homepage? An empty list, a form, or an error?

  • Open your browser to http://127.0.0.1:5000.

You should see a styled page with the heading "My Tickets" and the message "You have no tickets yet. Submit one above!" along with a blue Submit New Ticket button.

  • Click Submit New Ticket (or navigate to http://127.0.0.1:5000/submit).
  • Enter Cannot log in to email in the Subject field.
  • Enter I keep getting a password error when I try to sign in. in the Description field.
  • Click Submit Ticket.

You should be redirected to the homepage. Your ticket now appears in a table showing the subject, an "Open" status badge in blue, and the creation timestamp.

  • Submit one or two more test tickets to confirm they all appear in the list, newest first.

Page not loading or showing an error?

  • If you see a "TemplateNotFound" error, make sure your templates folder is directly inside the helpdesk folder (not nested inside another subfolder). The file names must be exactly base.html, index.html, and submit.html.
  • If you see a "BuildError" mentioning an endpoint, double-check that you added the placeholder login and register routes to app.py.
  • If the terminal shows a syntax error, check for missing colons or unmatched quotes in your Python code.

help me fix my Flask app that's showing an error when I load the page

✔️ Awesome, I've got everything!

Great. Double-check that you have saved all four files: app.py, templates/base.html, templates/index.html, and templates/submit.html.

ⓧ I'd like to double check the full code

Your app.py is over 30 lines, so scroll up and confirm you have these sections in order:

  • Imports and app = Flask(__name__) with secret_key.
  • DATABASE, get_db(), close_db(), init_db(), and get_current_user().
  • The / route (index) querying tickets and rendering index.html.
  • The /submit route (submit_ticket) handling GET and POST.
  • Placeholder /login and /register routes (these get replaced in Step 3).
  • The if __name__ == '__main__': block calling init_db() before app.run(debug=True).

Here is the full templates/index.html:

{% extends "base.html" %}
{% block title %}My Tickets{% endblock %}
{% block content %}
<h1>My Tickets</h1>
<a href="{{ url_for('submit_ticket') }}" class="btn">Submit New Ticket</a>
{% if tickets %}
<table>
    <tr>
        <th>Subject</th>
        <th>Status</th>
        <th>Created</th>
    </tr>
    {% for ticket in tickets %}
    <tr>
        <td>{{ ticket.subject }}</td>
        <td><span class="status status-{{ ticket.status|lower|replace(' ', '-') }}">{{ ticket.status }}</span></td>
        <td>{{ ticket.created_at }}</td>
    </tr>
    {% endfor %}
</table>
{% else %}
<p style="margin-top: 15px;">You have no tickets yet. Submit one above!</p>
{% endif %}
{% endblock %}

Here is the full templates/submit.html:

{% extends "base.html" %}
{% block title %}Submit Ticket{% endblock %}
{% block content %}
<h1>Submit a Ticket</h1>
<form method="POST">
    <label for="subject">Subject:</label>
    <input type="text" id="subject" name="subject" required>
    <label for="description">Description:</label>
    <textarea id="description" name="description" required></textarea>
    <button type="submit" style="margin-top: 15px;">Submit Ticket</button>
</form>
{% endblock %}

You now have a working ticket submission system. Customers can submit tickets and see them listed with their status. Next up, you will add user accounts so each person only sees their own tickets.

Add User Accounts and Login

Right now, your helpdesk has a major gap. Anyone can submit tickets anonymously, and everyone sees every ticket on the homepage.

A real helpdesk needs user accounts so customers only see their own tickets and agents have separate access. In this step, you will add a registration system, a login page, and tie each ticket to the user who created it.

In this step, get ready to:
  • Add a users table to the database and create a registration page.
  • Build login and logout functionality with secure password hashing.
  • Tie tickets to user accounts so customers only see their own.
Add the users table and registration page

Before anyone can log in, you need a place to store user accounts. You will create a users table in SQLite that holds each person's username, a hashed password, and their role (customer or agent).

  • In app.py, find the init_db() function and replace the entire function with this updated version:
def init_db():
    db = get_db()
    db.execute('''CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        username TEXT UNIQUE NOT NULL,
        password_hash TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'customer'
    )''')
    db.execute('''CREATE TABLE IF NOT EXISTS tickets (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL,
        subject TEXT NOT NULL,
        description TEXT NOT NULL,
        status TEXT NOT NULL DEFAULT 'Open',
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (user_id) REFERENCES users (id)
    )''')
    # Pre-create an agent account for testing
    existing_agent = db.execute('SELECT id FROM users WHERE username = ?', ('agent',)).fetchone()
    if not existing_agent:
        db.execute('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
                   ('agent', generate_password_hash('helpdesk123'), 'agent'))
    db.commit()

What does this code do?

  • The users table stores accounts with a unique username, a hashed password (never plain text), and a role that defaults to customer.
  • The tickets table now includes a user_id column linking each ticket to the user who created it.
  • The last block pre-creates an agent account (username agent, password helpdesk123) so you can test agent features later without manually inserting one.
  • Now find the get_current_user() function (currently it just returns None) and replace it with this version:
def get_current_user():
    # Check if a user is logged in by reading the session
    user_id = session.get('user_id')
    if user_id:
        db = get_db()
        return db.execute('SELECT * FROM users WHERE id = ?', (user_id,)).fetchone()
    return None

What does this code do?

Flask's session is a signed cookie that persists data between requests. This function checks if a user_id is stored in the session, and if so, looks up that user in the database. If nobody is logged in, it returns None.

  • Save app.py.
  • Stop the running server by pressing Ctrl+C in your terminal.
  • Delete the old database file so the new schema takes effect, by running:
del helpdesk.db
  • Restart the app by running:
python app.py

You should see the server start up with no errors. If you visit http://127.0.0.1:5000 in your browser, you will see an empty ticket list (since the database was recreated).

Seeing a database error?

  • Make sure you deleted helpdesk.db before restarting. The old table schema conflicts with the new one.
  • Confirm your init_db() function has both the users and tickets table definitions.

Still stuck? Help me fix a database error when restarting my Flask app after changing the schema.

  • Now add the registration route. Below the get_current_user() function (and above the existing @app.route('/') route), add this code:
@app.route('/register', methods=['GET', 'POST'])
def register():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        db = get_db()
        error = None
        if not username:
            error = 'Username is required.'
        elif not password:
            error = 'Password is required.'
        elif db.execute('SELECT id FROM users WHERE username = ?', (username,)).fetchone():
            error = 'Username already exists.'
        if error is None:
            # Hash the password so it's never stored in plain text
            db.execute('INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)',
                       (username, generate_password_hash(password), 'customer'))
            db.commit()
            flash('Registration successful! Please log in.')
            return redirect(url_for('login'))
        flash(error)
    return render_template('register.html')

What does this code do?

  • When the form is submitted (POST), the function validates the username and password, checks for duplicates, then inserts a new user row.
  • generate_password_hash() from werkzeug.security hashes the password using the scrypt algorithm. The database never stores the plain-text password.
  • On success, it redirects to the login page. On failure, it flashes the error message.
  • Save app.py.
  • Create a new file called register.html inside the templates folder, and paste this code:
{% extends "base.html" %}
{% block title %}Register{% endblock %}
{% block content %}
<h1>Register</h1>
<form method="POST">
    <label for="username">Username:</label>
    <input type="text" id="username" name="username" required>
    <label for="password">Password:</label>
    <input type="password" id="password" name="password" required>
    <button type="submit" style="margin-top: 15px;">Register</button>
</form>
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
{% endblock %}

What does this template do?

This template extends base.html to inherit the navigation and styling. The form posts to the same /register URL, which Flask handles with the POST branch of your register function.

  • Save register.html.
  • Restart the server by pressing Ctrl+C and running:
python app.py
  • Open http://127.0.0.1:5000/register in your browser.
  • Enter a username (e.g. alice) and a password, then click Register.

You should see an orange flash message saying "Registration successful! Please log in." and be redirected to the login page (which does not exist yet, so you will see a 404 error).

Not seeing the registration form?

  • Make sure register.html is saved inside the templates folder, not in the root helpdesk folder.
  • Confirm you restarted the server after adding the new route.

Still stuck? Help me figure out why my Flask registration page is not loading.

Build the login page and logout

Now that users can register, they need a way to log in. The login route will check the submitted password against the stored hash and start a session.

  • In app.py, add the login and logout routes directly below the register function you just added:
@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'POST':
        username = request.form['username']
        password = request.form['password']
        db = get_db()
        user = db.execute('SELECT * FROM users WHERE username = ?', (username,)).fetchone()
        # check_password_hash compares the stored hash against the submitted password
        if user is None or not check_password_hash(user['password_hash'], password):
            flash('Invalid username or password.')
        else:
            session.clear()
            session['user_id'] = user['id']
            return redirect(url_for('index'))
    return render_template('login.html')


@app.route('/logout')
def logout():
    session.clear()
    return redirect(url_for('login'))

What does this code do?

  • The login route looks up the user by username, then uses check_password_hash() to verify the submitted password matches the stored hash. It never compares plain-text passwords.
  • On success, it stores user['id'] in the session cookie. This is what get_current_user() reads on every request.
  • The logout route clears the session entirely, removing the logged-in state.
  • Save app.py.
  • Create a new file called login.html inside the templates folder, and paste this code:
{% extends "base.html" %}
{% block title %}Login{% endblock %}
{% block content %}
<h1>Login</h1>
<form method="POST">
    <label for="username">Username:</label>
    <input type="text" id="username" name="username" required>
    <label for="password">Password:</label>
    <input type="password" id="password" name="password" required>
    <button type="submit" style="margin-top: 15px;">Login</button>
</form>
<p>Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
{% endblock %}

What does this template do?

This login form posts the username and password to /login. If credentials are wrong, Flask flashes an error message that base.html renders in the orange bar at the top.

  • Save login.html.
  • Restart the server by pressing Ctrl+C and running:
python app.py
  • Open http://127.0.0.1:5000/login in your browser.
  • Log in with the pre-created agent account: username agent, password helpdesk123.

You should be redirected to the homepage. The navigation bar now shows "Logout (agent)" on the right side, proving the session is working.

Seeing 'Invalid username or password'?

  • Make sure you deleted helpdesk.db earlier. If the old database still exists, the agent account was never created.
  • Check that your init_db() function has the agent pre-creation block at the bottom.

Still stuck? Help me debug why the agent login is not working in my Flask app.

Tie tickets to user accounts

Registration and login are working. Now you need to connect them to the ticket system so each ticket belongs to a specific user and customers only see their own.

  • In app.py, find the existing index() route and replace the entire function with this version:
@app.route('/')
def index():
    user = get_current_user()
    if not user:
        return redirect(url_for('login'))
    if user['role'] == 'agent':
        return redirect(url_for('agent_dashboard'))
    db = get_db()
    # Only show tickets belonging to the logged-in customer
    tickets = db.execute('SELECT * FROM tickets WHERE user_id = ? ORDER BY created_at DESC', (user['id'],)).fetchall()
    return render_template('index.html', tickets=tickets, user=user)

What changed here?

  • If no user is logged in, the homepage now redirects to /login. Anonymous access is no longer possible.
  • If the logged-in user is an agent, they are redirected to the agent dashboard (which you will build in the next step).
  • The SQL query now filters tickets with WHERE user_id = ? so customers only see their own tickets.
  • Now find the existing submit_ticket() route and replace the entire function with this version:
@app.route('/submit', methods=['GET', 'POST'])
def submit_ticket():
    user = get_current_user()
    if not user:
        return redirect(url_for('login'))
    if request.method == 'POST':
        subject = request.form['subject']
        description = request.form['description']
        if not subject or not description:
            flash('Subject and description are required.')
        else:
            db = get_db()
            # Save the logged-in user's ID with the ticket
            db.execute('INSERT INTO tickets (user_id, subject, description) VALUES (?, ?, ?)',
                       (user['id'], subject, description))
            db.commit()
            flash('Ticket submitted successfully!')
            return redirect(url_for('index'))
    return render_template('submit.html', user=user)

What changed here?

  • The route now requires a logged-in user. If nobody is logged in, it redirects to /login.
  • The INSERT statement now includes user['id'] so each ticket is permanently linked to the user who created it.
  • The user object is passed to the template so the navigation bar can display the username.
  • Save app.py.
  • Stop the server with Ctrl+C and delete the database one more time (to clear any test data from earlier):
del helpdesk.db
  • Restart the app by running:
python app.py

Before you test, think about what should happen now. If you visit the homepage without logging in, what do you expect to see?

  • Open http://127.0.0.1:5000 in your browser.

You are redirected to the login page. Anonymous access is gone.

  • Click Register here and create a customer account with username alice.
  • Log in as alice.
  • Click New Ticket in the navigation and submit a ticket with subject Cannot reset password.
  • Click Logout (alice) in the navigation bar.
  • Register a second customer account with username bob.
  • Log in as bob and submit a ticket with subject Billing question.

On the homepage, bob only sees "Billing question". Log out and log back in as alice. Alice only sees "Cannot reset password". Each customer's tickets are completely isolated.

Both users see the same tickets?

  • Make sure your updated index() route includes WHERE user_id = ? in the SQL query.
  • Confirm you deleted helpdesk.db and restarted after making changes. Old tickets in the database may not have a user_id.

Still stuck? Help me debug why customers are seeing each other's tickets.

✔️ Awesome, I've got everything!

Great. Double check that you have saved both app.py and all template files.

ⓧ I'd like to double check the full code

Your app.py should now contain these sections in order. Scroll up and confirm each one is present:

  • Imports and app setup (Flask, secret_key, DATABASE).
  • get_db() and close_db() functions.
  • init_db() creating users and tickets tables, plus the agent pre-creation.
  • get_current_user() reading from the session.
  • index() route requiring login and filtering tickets by user.
  • register() route.
  • login() and logout() routes.
  • submit_ticket() route requiring login and saving user_id.
  • The if __name__ == '__main__' block at the bottom.

Your templates folder should now contain: base.html, index.html, submit.html, register.html, and login.html.

Your helpdesk now has real user accounts. Customers register, log in, and only see their own tickets. Next up, you will build the agent side so support staff can view all tickets and respond.

Build the Agent Dashboard and Responses

Customers can submit tickets now, but nobody on the other end can read them, respond, or change their status.

Without an agent side, the helpdesk is a one-way dead end. In this step you will build the agent dashboard, a ticket detail page with a response form, and connect the two sides so agents and customers can communicate through tickets.

In this step, get ready to:
  • Create an agent-only dashboard that shows all tickets from all customers.
  • Add a ticket detail page where agents can update status and post responses.
  • Let customers view agent responses on their own tickets.
Add the responses table and agent dashboard route

Agent responses need their own database table. Each response belongs to a specific ticket and records which user wrote it.

  • In app.py, find the init_db() function. Locate the line that says existing_agent = db.execute.
  • Add the following code directly above that line to create the responses table:
    db.execute('''CREATE TABLE IF NOT EXISTS responses (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        ticket_id INTEGER NOT NULL,
        user_id INTEGER NOT NULL,
        message TEXT NOT NULL,
        created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
        FOREIGN KEY (ticket_id) REFERENCES tickets (id),
        FOREIGN KEY (user_id) REFERENCES users (id)
    )''')

What does this table store?

Each row in the responses table holds one agent reply. The ticket_id column links the response to its parent ticket, and user_id records which agent wrote it.

  • Now scroll to the bottom of app.py. Find the line if __name__ == '__main__': and add the agent dashboard route directly above it:
@app.route('/agent')
def agent_dashboard():
    user = get_current_user()
    if not user or user['role'] != 'agent':
        return redirect(url_for('index'))
    db = get_db()
    tickets = db.execute('SELECT tickets.*, users.username FROM tickets JOIN users ON tickets.user_id = users.id ORDER BY tickets.created_at DESC').fetchall()
    return render_template('agent.html', tickets=tickets, user=user)

What does this route do?

  • The role check at the top redirects any non-agent user back to the homepage.
  • The SQL query joins the tickets and users tables so each ticket includes the customer's username.
  • Save app.py.
  • Check your terminal. Flask's debug mode auto-reloads when you save, so you should see a line containing Restarting with stat.

Server not restarting?

  • If you see a syntax error in the terminal, check that the new CREATE TABLE statement ends with ''') (triple quotes followed by a closing parenthesis).
  • If the server stopped completely, press Ctrl+C and re-run python app.py to see the full error message.

Why is my Flask server crashing after I added the responses table?

The route is ready but it references a template that does not exist yet. Next, create the template so agents can actually see the dashboard.

  • Create a new file called agent.html inside your templates folder and paste the following code:
{% extends "base.html" %}
{% block title %}Agent Dashboard{% endblock %}
{% block content %}
<h1>Agent Dashboard</h1>
<p>All tickets from all customers:</p>
{% if tickets %}
<table>
    <tr>
        <th>Subject</th>
        <th>Customer</th>
        <th>Status</th>
        <th>Created</th>
    </tr>
    {% for ticket in tickets %}
    <tr>
        <td><a href="{{ url_for('view_ticket', ticket_id=ticket.id) }}">{{ ticket.subject }}</a></td>
        <td>{{ ticket.username }}</td>
        <td><span class="status status-{{ ticket.status|lower|replace(' ', '-') }}">{{ ticket.status }}</span></td>
        <td>{{ ticket.created_at }}</td>
    </tr>
    {% endfor %}
</table>
{% else %}
<p>No tickets yet.</p>
{% endif %}
{% endblock %}

What does this template do?

  • It extends base.html so agents get the same navigation bar and styling as everyone else.
  • Each ticket row shows the subject as a clickable link, the customer who submitted it, the current status with color-coding, and the creation date.
  • Save agent.html.
  • Open your browser and go to http://127.0.0.1:5000/login.
  • Log in with username agent and password helpdesk123.

You should be redirected to the Agent Dashboard page. If you submitted test tickets earlier as a customer, they appear in the table. If not, you will see "No tickets yet."

Seeing a TemplateNotFound error?

  • Make sure agent.html is inside the templates folder, not the project root.
  • Check that the filename is exactly agent.html (lowercase, no extra spaces).

Why is Flask saying TemplateNotFound for agent.html?

Build the ticket detail page with responses

Clicking a ticket subject on the dashboard currently leads nowhere. You need a detail page that shows the full ticket and lets agents respond.

  • In app.py, find the end of the submit_ticket function. Add the following route directly below it:
@app.route('/ticket/<int:ticket_id>')
def view_ticket(ticket_id):
    user = get_current_user()
    if not user:
        return redirect(url_for('login'))
    db = get_db()
    ticket = db.execute('SELECT tickets.*, users.username FROM tickets JOIN users ON tickets.user_id = users.id WHERE tickets.id = ?', (ticket_id,)).fetchone()
    if not ticket:
        flash('Ticket not found.')
        return redirect(url_for('index'))
    if user['role'] == 'customer' and ticket['user_id'] != user['id']:
        flash('Access denied.')
        return redirect(url_for('index'))
    responses = db.execute('SELECT responses.*, users.username FROM responses JOIN users ON responses.user_id = users.id WHERE ticket_id = ? ORDER BY responses.created_at ASC', (ticket_id,)).fetchall()
    return render_template('ticket.html', ticket=ticket, responses=responses, user=user)

What does this route do?

  • The <int:ticket_id> URL parameter captures the ticket's ID from the URL.
  • The access check ensures customers can only view their own tickets. Agents can view any ticket.
  • The responses query fetches all replies for this ticket, sorted oldest-first so the conversation reads top to bottom.
  • Directly below the view_ticket function, add the route that handles agent responses:
@app.route('/ticket/<int:ticket_id>/respond', methods=['POST'])
def respond_ticket(ticket_id):
    user = get_current_user()
    if not user or user['role'] != 'agent':
        return redirect(url_for('login'))
    message = request.form['message']
    status = request.form.get('status')
    db = get_db()
    if message:
        db.execute('INSERT INTO responses (ticket_id, user_id, message) VALUES (?, ?, ?)',
                   (ticket_id, user['id'], message))
    if status:
        db.execute('UPDATE tickets SET status = ? WHERE id = ?', (status, ticket_id))
    db.commit()
    flash('Response posted.')
    return redirect(url_for('view_ticket', ticket_id=ticket_id))

What does this route do?

  • Only agents can hit this route. The role check at the top blocks customers.
  • It inserts the agent's message into the responses table and optionally updates the ticket's status.
  • After saving, it redirects back to the same ticket detail page so the agent can see their response appear.
  • Save app.py. Check your terminal shows the server reloading.

Now you need the template that renders the ticket detail page. This template serves both agents and customers, but only agents see the response form.

  • Create a new file called ticket.html inside your templates folder and paste the ticket details section:
{% extends "base.html" %}
{% block title %}Ticket: {{ ticket.subject }}{% endblock %}
{% block content %}
<h1>{{ ticket.subject }}</h1>
<p><strong>Status:</strong> <span class="status status-{{ ticket.status|lower|replace(' ', '-') }}">{{ ticket.status }}</span></p>
<p><strong>Submitted by:</strong> {{ ticket.username }}</p>
<p><strong>Created:</strong> {{ ticket.created_at }}</p>
<p><strong>Description:</strong></p>
<p>{{ ticket.description }}</p>

<h2>Responses</h2>
{% if responses %}
    {% for response in responses %}
    <div class="response">
        <div class="response-meta">{{ response.username }} - {{ response.created_at }}</div>
        <p>{{ response.message }}</p>
    </div>
    {% endfor %}
{% else %}
    <p>No responses yet.</p>
{% endif %}
{% endblock %}

What does this template show?

  • The ticket subject, status badge, submitter name, date, and full description.
  • All responses appear in chronological order, each styled with a blue left border and showing who wrote it.
  • Save ticket.html and return to the Agent Dashboard in your browser.
  • Click on any ticket subject in the table.

You should see the ticket detail page with the subject as a heading, the status badge, and "No responses yet." at the bottom. The page does not have a response form yet.

The last piece is the agent response form. Only agents should see it.

  • In ticket.html, find the line that says {% endblock %} at the very bottom of the file.
  • Replace that single line with the following code so the response form appears before the block ends:
{% if user.role == 'agent' %}
<h2>Post a Response</h2>
<form method="POST" action="{{ url_for('respond_ticket', ticket_id=ticket.id) }}">
    <label for="status">Update Status:</label>
    <select id="status" name="status">
        <option value="Open" {% if ticket.status == 'Open' %}selected{% endif %}>Open</option>
        <option value="In Progress" {% if ticket.status == 'In Progress' %}selected{% endif %}>In Progress</option>
        <option value="Closed" {% if ticket.status == 'Closed' %}selected{% endif %}>Closed</option>
    </select>
    <label for="message">Response:</label>
    <textarea id="message" name="message" required></textarea>
    <button type="submit" style="margin-top: 15px;">Send Response</button>
</form>
{% endif %}
{% endblock %}

What does this form do?

  • The {% if user.role == 'agent' %} check means customers never see the form. They can view responses but not post them.
  • The status dropdown lets agents change the ticket to Open, In Progress, or Closed.
  • The form posts to the respond_ticket route you added earlier.
  • Save ticket.html.
  • Refresh the ticket detail page in your browser.

You should now see a "Post a Response" section below the ticket details with a status dropdown and a message text area.

Don't see the response form?

  • Make sure you are logged in as agent, not a customer account. The form only appears for agents.
  • Check that you replaced the old {% endblock %} line. The new code must end with {% endif %} followed by {% endblock %} on the last line.

Why isn't the agent response form showing up on my ticket page?

Test the full agent-customer conversation flow

The code is in place. Before you test, think about what should happen when an agent posts a response and changes a ticket's status.

  • While logged in as agent, click on a customer ticket from the Agent Dashboard.
  • Change the Update Status dropdown to In Progress.
  • Type a response message in the Response text area. Something like Looking into this now. Will update shortly.
  • Click Send Response.

What do you expect to see after submitting? The page should reload showing your response in the responses section, with the status badge now reading "In Progress" in orange.

  • Confirm the response appears with your agent username and timestamp.
  • Confirm the status badge at the top changed to "In Progress".

Now check the customer's perspective.

  • Click Logout in the navigation bar.
  • Log in with the customer account you registered in the previous step.
  • Click on the ticket you just responded to.

You should see the agent's response displayed with the blue left border, the updated "In Progress" status, and no response form. Customers can read responses but cannot post them.

Response not appearing for the customer?

  • Make sure you clicked the same ticket you responded to as the agent. Check the subject line matches.
  • If the page shows an error, stop the server with Ctrl+C, delete helpdesk.db, and restart with python app.py to rebuild the database from scratch. You will need to re-register a customer and re-submit a ticket.

Why can't my customer see the agent's response on their ticket?

✔️ Awesome, I've got everything!

Great. Double check you have saved both app.py and all files in your templates folder.

ⓧ I'd like to double check the full code

Scroll up through your work and confirm each of these pieces is present in order:

  • In app.py: the CREATE TABLE IF NOT EXISTS responses statement inside init_db(), after the tickets table.
  • In app.py: the view_ticket route (after submit_ticket).
  • In app.py: the respond_ticket route (after view_ticket).
  • In app.py: the agent_dashboard route (after respond_ticket, before if __name__).
  • templates/agent.html: extends base.html, shows a table of all tickets with subject, customer, status, and date.
  • templates/ticket.html: extends base.html, shows ticket details, responses list, and the agent-only response form ending with {% endif %}{% endblock %}.

Your helpdesk is now a two-way communication system. Customers submit tickets, agents respond and manage status, and both sides see updates on the same ticket page. The Secret Mission takes this further by adding priority levels so agents can triage the most urgent issues first.

Secret mission

Add Priority Levels and Filtering

Right now every ticket looks the same in the queue. If a customer's login is completely broken AND someone has a minor formatting question, an agent has no way to spot the urgent issue first. Add a priority system with color-coded badges and filtering so agents can triage effectively.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

This project runs entirely locally, so there are no ongoing costs.

Resources you used:

  • A SQLite database file (helpdesk.db) stored in your project folder. This costs nothing and only exists on your local machine.
  • A Python virtual environment folder (venv) in your project folder.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep experimenting with the helpdesk app.

  • Your project files stay in the helpdesk folder on your Desktop.
  • To start the server again later, open your terminal, navigate to the helpdesk folder, activate the virtual environment, and run python app.py.
  • Your tickets and user accounts persist in helpdesk.db between sessions.

Pause - I'll come back to this later

No action needed. Everything is stored as local files on your machine, so there are no running processes or costs to worry about.

  • If the Flask development server is still running in your terminal, press Ctrl+C to stop it.
  • Your project will be waiting for you in the helpdesk folder whenever you are ready to return.

Delete - I don't want to use this again

Remove all project files to start fresh. This deletes your code, database, and virtual environment permanently.

  • If the Flask development server is still running, press Ctrl+C in the terminal to stop it.
  • Make sure you are not inside the helpdesk folder. Navigate to your Desktop first by running:
cd ~/Desktop
  • Delete the entire project folder by running this command on macOS or Linux:
rm -rf helpdesk
  • On Windows, run this instead in Command Prompt:
rmdir /s /q helpdesk
  • This removes app.py, the templates folder, helpdesk.db, and the venv virtual environment all at once.

Nice Work!

Nice Work!

You just built a fully functional helpdesk ticketing system from scratch using Python and Flask. Customers can register, submit tickets, and track responses. Agents can manage the entire queue from their own dashboard.

You've learned how to:

  • Build a multi-page web application with Python and Flask that handles routes, forms, and redirects.
  • Create a SQLite database with multiple related tables and write SQL queries to insert, select, update, and filter data.
  • Implement user authentication with secure password hashing and role-based access control that shows different views to customers and agents.
  • Secret Mission: Added a ticket priority system with color-coded badges and a filtering dropdown on the agent dashboard.

Ready to quiz yourself?