Flask Auth with Role-Based Access Control

Build a multi-role Flask app with admin, staff, and trekker dashboards.

Introduction

30 Second Summary

Every adventure organization needs a way to manage who can do what. Admins approve staff, staff coordinate treks, and trekkers book activities. Without proper access control, anyone could access anything.

In this project, you will build a Trekking Management Application with role-based authentication using Flask. You will create three user roles, each with their own dashboard and permissions, and give Admin the power to approve or reject staff registrations.

What You'll Build

A full-stack Flask application with session-based authentication, four database models, and an admin control panel for managing staff approvals.

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

  • A working multi-role authentication system with Admin, Trek Staff, and Trekker roles that each redirect to their own dashboard.
  • A SQLite database with four models (User, StaffProfile, Trek, Booking) created programmatically using Flask-SQLAlchemy.
  • A functional admin dashboard displaying system statistics and a staff approval workflow with approve and blacklist actions.
  • Secret Mission: Add a User Management page where Admin can view all registered trekkers and blacklist or unblacklist them.

Are there any prerequisites?

You need Python 3.13.14 installed on your machine and VS Code ready to go. Basic Python knowledge (variables, functions, and imports) is helpful but not required.

Before We Start

Every trekking platform needs a solid foundation before you can add features like booking and trek management. This foundation includes a consistent development environment, the right packages, and a clean file structure.

In this step, you will create a project folder, install Flask and its extensions, and set up all the empty files your application needs. Once this is done, you will have a ready-to-code workspace for building the authentication system.

In this step, get ready to:
  • Create a project folder and Python virtual environment.
  • Install Flask, Flask-Login, and Flask-SQLAlchemy.
  • Create the project file structure with all required templates.
Create a project folder and virtual environment

A virtual environment isolates your project's packages from your system Python. This prevents version conflicts between projects.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (backtick key, usually above Tab).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir trekking_app
cd trekking_app
  • Create a Python virtual environment by running:
python -m venv venv

Getting 'python not found'?

On macOS, you may need to use python3 instead of python. Run python3 -m venv venv instead.

Help me fix this

  • Activate the virtual environment:

macOS

  • Activate by running:
source venv/bin/activate

You should see (venv) appear at the beginning of your terminal prompt.

Windows

  • Activate by running:
venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt.

What is a virtual environment?

A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay contained to this project and do not affect other projects on your machine.

  • Now open this folder in VS Code properly by clicking File in the top menu bar.
  • Click Open Folder.
  • Navigate to your Desktop and select the trekking_app folder, then click Open.
  • If prompted to trust the authors, click Yes, I trust the authors.
  • Reopen the integrated terminal by pressing Ctrl+`.

Your terminal should still show (venv) at the prompt. If not, run the activation command again from the platform tab above.

Install required packages

Flask is the web framework. Flask-Login handles user sessions. Flask-SQLAlchemy connects Flask to a database.

  • Install all three packages by running this command:
pip install Flask==3.1.3 Flask-Login==0.6.3 Flask-SQLAlchemy==3.1.1

What does this command do?

pip is Python's package manager. The == syntax pins each package to an exact version so your project behaves the same way every time.

Flask 3.1.3 is the web framework that handles routes and templates. Flask-Login 0.6.3 manages user authentication sessions. Flask-SQLAlchemy 3.1.1 connects Flask to a SQLite database using Python objects.

  • Verify the installation by running:
pip list

You should see Flask, Flask-Login, and Flask-SQLAlchemy in the output along with their version numbers.

Packages not showing up?

Make sure your virtual environment is active. Check that (venv) appears at the start of your terminal prompt. If it does not, run the activation command from the platform tab above.

Help me troubleshoot this

Create the project file structure

Your project needs two Python files in the root and seven HTML templates inside a templates folder. Creating them now gives you a clear map of the application.

  • Create the templates folder by running:
mkdir templates
  • Create the root-level Python files and all template files:

macOS

  • Create all project files by running these commands:
touch app.py models.py
touch templates/base.html templates/login.html templates/register.html
touch templates/admin_dashboard.html templates/admin_manage_staff.html
touch templates/staff_dashboard.html templates/user_dashboard.html

Windows

  • Create all project files by running these commands:
New-Item -ItemType File -Path "app.py", "models.py"
New-Item -ItemType File -Path "templates/base.html", "templates/login.html", "templates/register.html"
New-Item -ItemType File -Path "templates/admin_dashboard.html", "templates/admin_manage_staff.html"
New-Item -ItemType File -Path "templates/staff_dashboard.html", "templates/user_dashboard.html"
  • Confirm the files exist by running:
ls

You should see app.py, models.py, templates, and venv in the output.

  • Check the templates folder contents by running:
ls templates

You should see all seven .html files listed.

What are these files for?

app.py holds your Flask application routes and configuration. models.py defines the database tables as Python classes.

The templates folder holds Jinja2 HTML templates. Flask automatically looks for templates in this folder by convention.

Missing files in the output?

Double-check that your terminal is inside the trekking_app folder. Run pwd (macOS) or cd (Windows) to confirm your current directory.

Help me fix my missing files

Your development environment is fully set up. Next, you will define the database models that power user accounts, staff profiles, treks, and bookings.

Define Database Models

Your project has all the files it needs, but they are empty. Before you can build login pages or dashboards, you need a database structure that knows what a user is, what a trek is, and how they connect.

In this step, you will define four database models using Flask-SQLAlchemy. Each model becomes a table in your SQLite database, and the relationships between them let you query connected data easily.

In this step, get ready to:
  • Configure the SQLAlchemy database instance and imports.
  • Define four models: User, StaffProfile, Trek, and Booking.
  • Set up foreign key relationships between models.
Set up the imports and database instance

Every model you define will inherit from a base class provided by Flask-SQLAlchemy. You need to create that base instance first, along with importing the tools each model depends on.

  • Open models.py in VS Code and add the following imports and database instance:
from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetime

# Create the SQLAlchemy database instance
# This object connects your models to the actual database
db = SQLAlchemy()

What does this code do?

These three imports bring in everything your models need:

  • SQLAlchemy gives you the base class and column types for defining database tables as Python classes.
  • UserMixin adds session management methods (like is_authenticated) that Flask-Login expects on your User model.
  • datetime provides timestamps for tracking when records are created.

The db object is the bridge between your Python classes and the SQLite database file. Every model you define will use db.Model as its parent class.

You will not see a change yet. These imports enable the model definitions coming next.

Define the User and StaffProfile models

The User model stores every person who registers on the platform. A staff member also gets a StaffProfile that tracks their approval status separately from login credentials.

  • Below the db = SQLAlchemy() line, add two blank lines and then paste the User model:
class User(UserMixin, db.Model):
    # Primary key auto-increments for each new user
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    # Stores the hashed password, never plaintext
    password_hash = db.Column(db.String(256), nullable=False)
    # Role determines access level: 'admin', 'staff', or 'user'
    role = db.Column(db.String(20), nullable=False)
    is_blacklisted = db.Column(db.Boolean, default=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    # One-to-one: a staff user has exactly one profile
    staff_profile = db.relationship('StaffProfile', backref='user', uselist=False)
    # One-to-many: a user can have multiple bookings
    bookings = db.relationship('Booking', backref='user')

What does this code do?

The User class maps to a user table in the database. It inherits from both UserMixin (for Flask-Login session handling) and db.Model (for SQLAlchemy ORM features).

The role column is what makes role-based access control possible. A single table holds admins, staff, and trekkers. The is_blacklisted flag lets admin block any user from logging in.

The two db.relationship() lines do not create columns. They tell SQLAlchemy how to load related records. The uselist=False on staff_profile means each user has at most one staff profile, not a list.

  • Below the User class, add two blank lines and then paste the StaffProfile model:
class StaffProfile(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    # Foreign key linking this profile to a User record
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    name = db.Column(db.String(100), nullable=False)
    contact = db.Column(db.String(100))
    # Status tracks approval: 'pending', 'approved', or 'blacklisted'
    status = db.Column(db.String(20), default='pending')

    # One-to-many: a staff member can be assigned multiple treks
    assigned_treks = db.relationship('Trek', backref='assigned_staff')

What does this code do?

StaffProfile exists as a separate table because not every user is staff. Only users with role 'staff' get a profile created during registration.

The user_id foreign key connects each profile back to its User record. The status column defaults to 'pending', meaning new staff must wait for admin approval before they can log in and access their dashboard.

You will not see a change yet. The Trek and Booking models you define next complete the relationship chain.

Define the Trek and Booking models

Treks are the core product of the application. Bookings connect users to treks. These two models close the loop between staff, trekkers, and the adventures they manage or join.

  • Below the StaffProfile class, add two blank lines and then paste the Trek model:
class Trek(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(200), nullable=False)
    location = db.Column(db.String(200), nullable=False)
    difficulty = db.Column(db.String(20), nullable=False)
    # Duration in days
    duration = db.Column(db.Integer, nullable=False)
    available_slots = db.Column(db.Integer, nullable=False)
    # Links this trek to the staff member responsible for it
    assigned_staff_id = db.Column(db.Integer, db.ForeignKey('staff_profile.id'), nullable=True)
    status = db.Column(db.String(20), default='Pending')
    start_date = db.Column(db.Date)
    end_date = db.Column(db.Date)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    # One-to-many: a trek can have multiple bookings
    bookings = db.relationship('Booking', backref='trek')

What does this code do?

The Trek model holds everything about an adventure: where it goes, how hard it is, how long it lasts, and how many slots are open.

The assigned_staff_id foreign key points to a StaffProfile. This is nullable=True because a trek can exist before any staff member is assigned to lead it. The status column lets admin control whether a trek is open for bookings.

  • Below the Trek class, add two blank lines and then paste the Booking model:
class Booking(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    # Foreign keys linking the booking to a user and a trek
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    trek_id = db.Column(db.Integer, db.ForeignKey('trek.id'), nullable=False)
    booking_date = db.Column(db.DateTime, default=datetime.utcnow)
    # Tracks booking lifecycle: 'Booked', 'Cancelled', etc.
    status = db.Column(db.String(20), default='Booked')

What does this code do?

Booking is the join table between users and treks. Each row records one trekker signing up for one trek, along with when they booked and the current status of that booking.

The two foreign keys (user_id and trek_id) let you query in both directions. You can find all bookings for a user or all bookings for a trek.

  • Save models.py.
  • Verify there are no syntax errors by running this command in your terminal:
python -c "from models import db, User, StaffProfile, Trek, Booking; print('All models imported successfully')"

You should see All models imported successfully printed in your terminal. This confirms Python can read your file, all imports resolve, and the class definitions have no errors.

Seeing an ImportError or SyntaxError?

  • If you see ModuleNotFoundError: No module named 'flask_sqlalchemy', your virtual environment is not activated. Run venv\Scripts\activate first.
  • If you see a SyntaxError, check that every db.Column(...) line is indented with four spaces under its class.
  • Make sure there are no missing commas between column arguments like primary_key=True and nullable=False.

Help me debug my models.py import error.

✔️ Awesome, I've got everything!

Great. Double check you have saved models.py and move on to the next step.

ⓧ I'd like to double check the full code

Here is the complete models.py file. Compare yours against it:

from flask_sqlalchemy import SQLAlchemy
from flask_login import UserMixin
from datetime import datetime

db = SQLAlchemy()


class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    username = db.Column(db.String(80), unique=True, nullable=False)
    email = db.Column(db.String(120), unique=True, nullable=False)
    password_hash = db.Column(db.String(256), nullable=False)
    role = db.Column(db.String(20), nullable=False)
    is_blacklisted = db.Column(db.Boolean, default=False)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    staff_profile = db.relationship('StaffProfile', backref='user', uselist=False)
    bookings = db.relationship('Booking', backref='user')


class StaffProfile(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    name = db.Column(db.String(100), nullable=False)
    contact = db.Column(db.String(100))
    status = db.Column(db.String(20), default='pending')

    assigned_treks = db.relationship('Trek', backref='assigned_staff')


class Trek(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    name = db.Column(db.String(200), nullable=False)
    location = db.Column(db.String(200), nullable=False)
    difficulty = db.Column(db.String(20), nullable=False)
    duration = db.Column(db.Integer, nullable=False)
    available_slots = db.Column(db.Integer, nullable=False)
    assigned_staff_id = db.Column(db.Integer, db.ForeignKey('staff_profile.id'), nullable=True)
    status = db.Column(db.String(20), default='Pending')
    start_date = db.Column(db.Date)
    end_date = db.Column(db.Date)
    created_at = db.Column(db.DateTime, default=datetime.utcnow)

    bookings = db.relationship('Booking', backref='trek')


class Booking(db.Model):
    id = db.Column(db.Integer, primary_key=True)
    user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
    trek_id = db.Column(db.Integer, db.ForeignKey('trek.id'), nullable=False)
    booking_date = db.Column(db.DateTime, default=datetime.utcnow)
    status = db.Column(db.String(20), default='Booked')

Why separate models from app.py?

Keeping models in their own file prevents circular imports. Your app.py will import from models.py, but models.py never needs to know about app.py. This one-way dependency keeps the project clean as it grows.

Your database blueprint is ready. Next up, you will build the Flask application itself with login, registration, and role-based access control that brings these models to life.

Build Authentication and Core Routes

Your database models are defined and ready to store data. Now you need the application logic that ties everything together.

In this step, you will build the main app.py file. This file creates the Flask application, handles user authentication (login, register, logout), enforces role-based access control through custom decorators, and defines all the core routes for each user type.

In this step, get ready to:
  • Configure the Flask app with extensions and a database initialization function.
  • Create role-based access decorators that protect routes by user type.
  • Write authentication routes and role-specific dashboard routes.
Set up the Flask app and extensions

Every Flask application starts with creating an app instance and configuring it. You also need to connect Flask-Login (for session management) and Flask-SQLAlchemy (for database access) to your app.

  • In VS Code, open app.py from the Explorer sidebar and add the following imports and app configuration:
from flask import Flask, render_template, redirect, url_for, request, flash, abort
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from models import db, User, StaffProfile, Trek, Booking
from functools import wraps

# Create the Flask application instance
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-change-this'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///trekking.db'

# Connect SQLAlchemy to the app
db.init_app(app)

# Set up Flask-Login for session management
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'


# Callback that loads a user from the database by their ID
@login_manager.user_loader
def load_user(user_id):
    return db.session.get(User, int(user_id))

What does this code do?

The first section imports everything you need from Flask, Flask-Login, Werkzeug (for password hashing), and your own models.py file.

The app configuration sets a SECRET_KEY (required for sessions) and a SQLALCHEMY_DATABASE_URI pointing to a SQLite database file. The user_loader callback tells Flask-Login how to reload a user from their stored session ID on every request.

Why use db.session.get() instead of query.get()?

The db.session.get() method is the modern SQLAlchemy 2.x approach for loading a record by primary key. It replaces the older Model.query.get() pattern and is the recommended way going forward.

Next, you need a function that creates the database tables and seeds an admin user the first time the app runs.

  • Add the init_db() function below the load_user function:
def init_db():
    with app.app_context():
        # Create all database tables from the models
        db.create_all()
        # Check if an admin user already exists
        admin = User.query.filter_by(role='admin').first()
        if not admin:
            # Seed the database with a default admin account
            admin = User(
                username='admin',
                email='admin@trekking.com',
                password_hash=generate_password_hash('admin123'),
                role='admin'
            )
            db.session.add(admin)
            db.session.commit()

What does init_db() do?

This function runs inside an application context so SQLAlchemy can access the database configuration. It calls db.create_all() to build all tables from your models.

It then checks whether an admin user exists. If not, it creates one with the username admin and password admin123. The password is hashed using Werkzeug before storage so it is never stored in plain text.

Create role-based access decorators

Decorators let you wrap a route with an access check. If the user does not have the correct role, they get redirected instead of seeing the page.

  • Add these three decorator functions below init_db():
def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or current_user.role != 'admin':
            flash('Access denied. Admin privileges required.', 'danger')
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function


def staff_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or current_user.role != 'staff':
            flash('Access denied. Staff privileges required.', 'danger')
            return redirect(url_for('login'))
        if current_user.staff_profile and current_user.staff_profile.status != 'approved':
            flash('Your account is pending approval from admin.', 'warning')
            return redirect(url_for('logout'))
        return f(*args, **kwargs)
    return decorated_function


def user_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or current_user.role != 'user':
            flash('Access denied.', 'danger')
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function

What do these decorators do?

  • admin_required checks that the current user is logged in AND has the admin role. If not, it flashes an error and redirects to the login page.
  • staff_required does the same role check for staff, but adds an extra check: even if the user has the staff role, their StaffProfile.status must be approved. Pending staff get logged out.
  • user_required checks for the user role (trekkers).

Why custom decorators instead of a library?

Flask-Login provides @login_required to check if someone is logged in, but it does not know about roles. Writing your own decorators with functools.wraps gives you full control over the access logic without adding another dependency.

Write authentication and core routes

Routes are the URLs your app responds to. You need routes for logging in, registering, logging out, and displaying the correct dashboard for each role.

  • Add the home redirect route and login route below your decorators:
@app.route('/')
def home():
    # Redirect authenticated users to their role-specific dashboard
    if current_user.is_authenticated:
        if current_user.role == 'admin':
            return redirect(url_for('admin_dashboard'))
        elif current_user.role == 'staff':
            return redirect(url_for('staff_dashboard'))
        else:
            return redirect(url_for('user_dashboard'))
    return redirect(url_for('login'))


@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('home'))
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        user = User.query.filter_by(username=username).first()
        if user and check_password_hash(user.password_hash, password):
            # Block blacklisted users from logging in
            if user.is_blacklisted:
                flash('Your account has been blacklisted. Contact admin.', 'danger')
                return redirect(url_for('login'))
            # Block staff who are not yet approved
            if user.role == 'staff' and user.staff_profile:
                if user.staff_profile.status == 'pending':
                    flash('Your account is pending admin approval.', 'warning')
                    return redirect(url_for('login'))
                elif user.staff_profile.status == 'blacklisted':
                    flash('Your staff account has been blacklisted.', 'danger')
                    return redirect(url_for('login'))
            login_user(user)
            flash('Login successful!', 'success')
            return redirect(url_for('home'))
        else:
            flash('Invalid username or password.', 'danger')
    return render_template('login.html')

What does the login route do?

The home() route acts as a traffic director. It checks the user's role and sends them to the right dashboard.

The login() route handles both GET requests (showing the form) and POST requests (processing the form). Before logging someone in, it checks three things: is the password correct, is the account blacklisted, and (for staff) is the account approved.

  • Add the register route below the login route:
@app.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('home'))
    if request.method == 'POST':
        username = request.form.get('username')
        email = request.form.get('email')
        password = request.form.get('password')
        role = request.form.get('role')
        name = request.form.get('name', username)
        contact = request.form.get('contact', '')

        # Validate role selection
        if role not in ['staff', 'user']:
            flash('Invalid role selected.', 'danger')
            return redirect(url_for('register'))

        # Check for duplicate username
        if User.query.filter_by(username=username).first():
            flash('Username already exists.', 'danger')
            return redirect(url_for('register'))

        # Check for duplicate email
        if User.query.filter_by(email=email).first():
            flash('Email already registered.', 'danger')
            return redirect(url_for('register'))

        # Create the new user with a hashed password
        user = User(
            username=username,
            email=email,
            password_hash=generate_password_hash(password),
            role=role
        )
        db.session.add(user)
        db.session.commit()

        # If staff, also create a StaffProfile with pending status
        if role == 'staff':
            staff_profile = StaffProfile(
                user_id=user.id,
                name=name,
                contact=contact,
                status='pending'
            )
            db.session.add(staff_profile)
            db.session.commit()
            flash('Registration successful! Please wait for admin approval.', 'info')
        else:
            flash('Registration successful! You can now login.', 'success')

        return redirect(url_for('login'))
    return render_template('register.html')

What does the register route do?

The register route validates the form data, checks for duplicate usernames and emails, then creates a new User record with a hashed password.

If the user registers as staff, the route also creates a StaffProfile with status='pending'. This means staff cannot access their dashboard until an admin approves them.

  • Add the logout route and all admin routes below the register route:
@app.route('/logout')
@login_required
def logout():
    logout_user()
    flash('Logged out successfully.', 'success')
    return redirect(url_for('login'))


@app.route('/admin/dashboard')
@login_required
@admin_required
def admin_dashboard():
    # Gather statistics for the admin overview
    total_treks = Trek.query.count()
    total_users = User.query.filter_by(role='user').count()
    total_staff = User.query.filter_by(role='staff').count()
    total_bookings = Booking.query.count()
    pending_staff = StaffProfile.query.filter_by(status='pending').count()
    return render_template('admin_dashboard.html',
                           total_treks=total_treks,
                           total_users=total_users,
                           total_staff=total_staff,
                           total_bookings=total_bookings,
                           pending_staff=pending_staff)


@app.route('/admin/manage-staff')
@login_required
@admin_required
def admin_manage_staff():
    # Load all staff profiles for the management table
    staff_profiles = StaffProfile.query.all()
    return render_template('admin_manage_staff.html', staff_profiles=staff_profiles)


@app.route('/admin/approve-staff/<int:staff_id>')
@login_required
@admin_required
def approve_staff(staff_id):
    staff = db.session.get(StaffProfile, staff_id)
    if not staff:
        abort(404)
    staff.status = 'approved'
    db.session.commit()
    flash(f'Staff "{staff.name}" has been approved.', 'success')
    return redirect(url_for('admin_manage_staff'))


@app.route('/admin/blacklist-staff/<int:staff_id>')
@login_required
@admin_required
def blacklist_staff(staff_id):
    staff = db.session.get(StaffProfile, staff_id)
    if not staff:
        abort(404)
    staff.status = 'blacklisted'
    staff.user.is_blacklisted = True
    db.session.commit()
    flash(f'Staff "{staff.name}" has been blacklisted.', 'warning')
    return redirect(url_for('admin_manage_staff'))

What do the admin routes do?

  • admin_dashboard queries the database for statistics (total treks, users, staff, bookings) and passes them to the template.
  • admin_manage_staff loads all staff profiles so the admin can see their status.
  • approve_staff sets a staff profile's status to approved, allowing them to log in.
  • blacklist_staff sets the status to blacklisted and also flags the user record, blocking future logins entirely.

Notice how each admin route stacks @login_required and @admin_required. Flask-Login checks authentication first, then your custom decorator checks the role.

  • Add the staff dashboard, user dashboard, and the main entry point to finish the file:
@app.route('/staff/dashboard')
@login_required
@staff_required
def staff_dashboard():
    # Show treks assigned to this staff member
    assigned_treks = Trek.query.filter_by(assigned_staff_id=current_user.staff_profile.id).all()
    return render_template('staff_dashboard.html', assigned_treks=assigned_treks)


@app.route('/user/dashboard')
@login_required
@user_required
def user_dashboard():
    # Show open treks and the user's booking history
    available_treks = Trek.query.filter_by(status='Open').all()
    my_bookings = Booking.query.filter_by(user_id=current_user.id).all()
    return render_template('user_dashboard.html', available_treks=available_treks, my_bookings=my_bookings)


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

What does the main block do?

The if __name__ == '__main__': block runs only when you execute the file directly. It calls init_db() to create tables and seed the admin user, then starts the development server on http://127.0.0.1:5000.

  • Save app.py.
  • In your terminal, verify there are no import errors by running:
python app.py

You should see output indicating the Flask server is running. Press Ctrl+C to stop it for now. You will test it fully after building the templates in the next step.

Seeing a ModuleNotFoundError?

  • Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
  • Confirm that models.py is in the same folder as app.py. The import from models import db, User, StaffProfile, Trek, Booking requires both files at the same level.
  • If you see an error about Flask, run pip list to confirm Flask is installed in your active environment.

Help me debug a ModuleNotFoundError when running my Flask app.

✔️ Awesome, I've got everything!

Great. Double check you have saved app.py and that the server started without errors.

ⓧ I'd like to double check the full code

Here is the complete app.py file for comparison:

from flask import Flask, render_template, redirect, url_for, request, flash, abort
from flask_login import LoginManager, login_user, logout_user, login_required, current_user
from werkzeug.security import generate_password_hash, check_password_hash
from models import db, User, StaffProfile, Trek, Booking
from functools import wraps

app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-change-this'
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///trekking.db'

db.init_app(app)

login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login'


@login_manager.user_loader
def load_user(user_id):
    return db.session.get(User, int(user_id))


def admin_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or current_user.role != 'admin':
            flash('Access denied. Admin privileges required.', 'danger')
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function


def staff_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or current_user.role != 'staff':
            flash('Access denied. Staff privileges required.', 'danger')
            return redirect(url_for('login'))
        if current_user.staff_profile and current_user.staff_profile.status != 'approved':
            flash('Your account is pending approval from admin.', 'warning')
            return redirect(url_for('logout'))
        return f(*args, **kwargs)
    return decorated_function


def user_required(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        if not current_user.is_authenticated or current_user.role != 'user':
            flash('Access denied.', 'danger')
            return redirect(url_for('login'))
        return f(*args, **kwargs)
    return decorated_function


def init_db():
    with app.app_context():
        db.create_all()
        admin = User.query.filter_by(role='admin').first()
        if not admin:
            admin = User(
                username='admin',
                email='admin@trekking.com',
                password_hash=generate_password_hash('admin123'),
                role='admin'
            )
            db.session.add(admin)
            db.session.commit()


@app.route('/')
def home():
    if current_user.is_authenticated:
        if current_user.role == 'admin':
            return redirect(url_for('admin_dashboard'))
        elif current_user.role == 'staff':
            return redirect(url_for('staff_dashboard'))
        else:
            return redirect(url_for('user_dashboard'))
    return redirect(url_for('login'))


@app.route('/login', methods=['GET', 'POST'])
def login():
    if current_user.is_authenticated:
        return redirect(url_for('home'))
    if request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        user = User.query.filter_by(username=username).first()
        if user and check_password_hash(user.password_hash, password):
            if user.is_blacklisted:
                flash('Your account has been blacklisted. Contact admin.', 'danger')
                return redirect(url_for('login'))
            if user.role == 'staff' and user.staff_profile:
                if user.staff_profile.status == 'pending':
                    flash('Your account is pending admin approval.', 'warning')
                    return redirect(url_for('login'))
                elif user.staff_profile.status == 'blacklisted':
                    flash('Your staff account has been blacklisted.', 'danger')
                    return redirect(url_for('login'))
            login_user(user)
            flash('Login successful!', 'success')
            return redirect(url_for('home'))
        else:
            flash('Invalid username or password.', 'danger')
    return render_template('login.html')


@app.route('/register', methods=['GET', 'POST'])
def register():
    if current_user.is_authenticated:
        return redirect(url_for('home'))
    if request.method == 'POST':
        username = request.form.get('username')
        email = request.form.get('email')
        password = request.form.get('password')
        role = request.form.get('role')
        name = request.form.get('name', username)
        contact = request.form.get('contact', '')

        if role not in ['staff', 'user']:
            flash('Invalid role selected.', 'danger')
            return redirect(url_for('register'))

        if User.query.filter_by(username=username).first():
            flash('Username already exists.', 'danger')
            return redirect(url_for('register'))

        if User.query.filter_by(email=email).first():
            flash('Email already registered.', 'danger')
            return redirect(url_for('register'))

        user = User(
            username=username,
            email=email,
            password_hash=generate_password_hash(password),
            role=role
        )
        db.session.add(user)
        db.session.commit()

        if role == 'staff':
            staff_profile = StaffProfile(
                user_id=user.id,
                name=name,
                contact=contact,
                status='pending'
            )
            db.session.add(staff_profile)
            db.session.commit()
            flash('Registration successful! Please wait for admin approval.', 'info')
        else:
            flash('Registration successful! You can now login.', 'success')

        return redirect(url_for('login'))
    return render_template('register.html')


@app.route('/logout')
@login_required
def logout():
    logout_user()
    flash('Logged out successfully.', 'success')
    return redirect(url_for('login'))


@app.route('/admin/dashboard')
@login_required
@admin_required
def admin_dashboard():
    total_treks = Trek.query.count()
    total_users = User.query.filter_by(role='user').count()
    total_staff = User.query.filter_by(role='staff').count()
    total_bookings = Booking.query.count()
    pending_staff = StaffProfile.query.filter_by(status='pending').count()
    return render_template('admin_dashboard.html',
                           total_treks=total_treks,
                           total_users=total_users,
                           total_staff=total_staff,
                           total_bookings=total_bookings,
                           pending_staff=pending_staff)


@app.route('/admin/manage-staff')
@login_required
@admin_required
def admin_manage_staff():
    staff_profiles = StaffProfile.query.all()
    return render_template('admin_manage_staff.html', staff_profiles=staff_profiles)


@app.route('/admin/approve-staff/<int:staff_id>')
@login_required
@admin_required
def approve_staff(staff_id):
    staff = db.session.get(StaffProfile, staff_id)
    if not staff:
        abort(404)
    staff.status = 'approved'
    db.session.commit()
    flash(f'Staff "{staff.name}" has been approved.', 'success')
    return redirect(url_for('admin_manage_staff'))


@app.route('/admin/blacklist-staff/<int:staff_id>')
@login_required
@admin_required
def blacklist_staff(staff_id):
    staff = db.session.get(StaffProfile, staff_id)
    if not staff:
        abort(404)
    staff.status = 'blacklisted'
    staff.user.is_blacklisted = True
    db.session.commit()
    flash(f'Staff "{staff.name}" has been blacklisted.', 'warning')
    return redirect(url_for('admin_manage_staff'))


@app.route('/staff/dashboard')
@login_required
@staff_required
def staff_dashboard():
    assigned_treks = Trek.query.filter_by(assigned_staff_id=current_user.staff_profile.id).all()
    return render_template('staff_dashboard.html', assigned_treks=assigned_treks)


@app.route('/user/dashboard')
@login_required
@user_required
def user_dashboard():
    available_treks = Trek.query.filter_by(status='Open').all()
    my_bookings = Booking.query.filter_by(user_id=current_user.id).all()
    return render_template('user_dashboard.html', available_treks=available_treks, my_bookings=my_bookings)


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

Your entire application backend is now in place. Next up, you will build the HTML templates that give each route a visible interface in the browser.

Create Templates with Bootstrap

Your routes know what data to pass and which template to render. But right now, every template file is empty.

In this step, you will build all seven HTML templates using Jinja2 templating and Bootstrap 5 for styling. Each template extends a shared base layout so you only write the navbar and flash messages once.

In this step, get ready to:
  • Create the base template with Bootstrap CDN, navigation, and flash messages.
  • Build login and registration forms.
  • Build role-specific dashboards for Admin, Staff, and Trekkers.
Create the base template

Every page in the app shares a navbar, flash message area, and Bootstrap styling. A base template defines this shared structure once, and child templates fill in just the unique content.

  • In VS Code, open base.html inside the templates folder.
  • Replace the empty file with the following code:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Trekking Management{% endblock %}</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
</head>
<body>
    <!-- Navigation bar with brand name and user info -->
    <nav class="navbar navbar-expand-lg navbar-dark bg-success">
        <div class="container">
            <a class="navbar-brand" href="{{ url_for('home') }}">Trekking Manager</a>
            <div class="navbar-nav ms-auto">
                {% if current_user.is_authenticated %}
                <span class="navbar-text me-3">Welcome, {{ current_user.username }} ({{ current_user.role }})</span>
                <a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
                {% else %}
                <a class="nav-link" href="{{ url_for('login') }}">Login</a>
                <a class="nav-link" href="{{ url_for('register') }}">Register</a>
                {% endif %}
            </div>
        </div>
    </nav>

    <!-- Main content area with flash messages -->
    <div class="container mt-4">
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% if messages %}
        {% for category, message in messages %}
        <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
            {{ message }}
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>
        {% endfor %}
        {% endif %}
        {% endwith %}

        {% block content %}{% endblock %}
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
</body>
</html>

What does this template do?

The {% block title %} and {% block content %} tags define placeholders that child templates override. This avoids repeating the navbar and Bootstrap includes in every page.

The flash message loop reads messages set by flash() in your routes and renders them as Bootstrap alerts. The category variable maps directly to Bootstrap alert classes like alert-success or alert-danger.

  • Save base.html.
  • You will not see a visual change yet. The next templates will extend this base and produce visible pages when the app runs.

Seeing a Jinja2 syntax error later?

Make sure every {% block %} has a matching {% endblock %}. Also check that the Bootstrap CDN link is on a single line with no line breaks inside the href attribute.

Help me debug a Jinja2 template error in my base.html

✔️ Awesome, I've got everything!

Make sure you have saved base.html before moving on.

ⓧ I'd like to double check the full code

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>{% block title %}Trekking Management{% endblock %}</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-success">
        <div class="container">
            <a class="navbar-brand" href="{{ url_for('home') }}">Trekking Manager</a>
            <div class="navbar-nav ms-auto">
                {% if current_user.is_authenticated %}
                <span class="navbar-text me-3">Welcome, {{ current_user.username }} ({{ current_user.role }})</span>
                <a class="nav-link" href="{{ url_for('logout') }}">Logout</a>
                {% else %}
                <a class="nav-link" href="{{ url_for('login') }}">Login</a>
                <a class="nav-link" href="{{ url_for('register') }}">Register</a>
                {% endif %}
            </div>
        </div>
    </nav>

    <div class="container mt-4">
        {% with messages = get_flashed_messages(with_categories=true) %}
        {% if messages %}
        {% for category, message in messages %}
        <div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
            {{ message }}
            <button type="button" class="btn-close" data-bs-dismiss="alert"></button>
        </div>
        {% endfor %}
        {% endif %}
        {% endwith %}

        {% block content %}{% endblock %}
    </div>

    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js" integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI" crossorigin="anonymous"></script>
</body>
</html>
Create authentication templates

Users need forms to log in and register. Both templates extend base.html so they inherit the navbar and Bootstrap styling automatically.

  • Open login.html inside the templates folder.
  • Replace the empty file with the following code:
{% extends 'base.html' %}
{% block title %}Login - Trekking Management{% endblock %}
{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <div class="card">
            <!-- Green header matching the navbar theme -->
            <div class="card-header bg-success text-white">
                <h4 class="mb-0">Login</h4>
            </div>
            <div class="card-body">
                <!-- Login form posts to the same /login route -->
                <form method="POST">
                    <div class="mb-3">
                        <label for="username" class="form-label">Username</label>
                        <input type="text" class="form-control" id="username" name="username" required>
                    </div>
                    <div class="mb-3">
                        <label for="password" class="form-label">Password</label>
                        <input type="password" class="form-control" id="password" name="password" required>
                    </div>
                    <button type="submit" class="btn btn-success w-100">Login</button>
                </form>
                <p class="mt-3 text-center">Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
            </div>
        </div>
    </div>
</div>
{% endblock %}

What does this template do?

The {% extends 'base.html' %} line tells Jinja2 to use the base template's structure. The form uses method="POST" with no action attribute, which means it submits back to the same URL. Flask's login route handles both GET (show form) and POST (process form).

  • Save login.html.
  • Now open register.html inside the templates folder.
  • Replace the empty file with the following code:
{% extends 'base.html' %}
{% block title %}Register - Trekking Management{% endblock %}
{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <div class="card">
            <div class="card-header bg-success text-white">
                <h4 class="mb-0">Register</h4>
            </div>
            <div class="card-body">
                <!-- Registration form with role selection -->
                <form method="POST">
                    <div class="mb-3">
                        <label for="username" class="form-label">Username</label>
                        <input type="text" class="form-control" id="username" name="username" required>
                    </div>
                    <div class="mb-3">
                        <label for="email" class="form-label">Email</label>
                        <input type="email" class="form-control" id="email" name="email" required>
                    </div>
                    <div class="mb-3">
                        <label for="password" class="form-label">Password</label>
                        <input type="password" class="form-control" id="password" name="password" required>
                    </div>
                    <div class="mb-3">
                        <!-- Role dropdown determines user type -->
                        <label for="role" class="form-label">Register as</label>
                        <select class="form-select" id="role" name="role" required>
                            <option value="user">Trekker (User)</option>
                            <option value="staff">Trek Staff</option>
                        </select>
                    </div>
                    <div class="mb-3">
                        <label for="name" class="form-label">Full Name</label>
                        <input type="text" class="form-control" id="name" name="name" required>
                    </div>
                    <div class="mb-3">
                        <label for="contact" class="form-label">Contact Details</label>
                        <input type="text" class="form-control" id="contact" name="contact" placeholder="Phone or email (required for staff)">
                    </div>
                    <button type="submit" class="btn btn-success w-100">Register</button>
                </form>
                <p class="mt-3 text-center">Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
            </div>
        </div>
    </div>
</div>
{% endblock %}

What does the role dropdown do?

The <select> dropdown lets users choose between Trekker and Trek Staff. The value attribute (user or staff) is what gets sent to your Flask route. The route uses this value to decide whether to create a StaffProfile record with pending status.

  • Save register.html.
  • You will not see these forms until the app runs in the next step.

Form not submitting correctly later?

Check that each name attribute on your form inputs matches exactly what app.py expects (e.g. name="username", name="role"). A mismatch means request.form.get() returns None.

Help me debug why my Flask form is not submitting data correctly

✔️ Awesome, I've got everything!

Make sure both login.html and register.html are saved.

ⓧ I'd like to double check the full code

{% extends 'base.html' %}
{% block title %}Login - Trekking Management{% endblock %}
{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <div class="card">
            <div class="card-header bg-success text-white">
                <h4 class="mb-0">Login</h4>
            </div>
            <div class="card-body">
                <form method="POST">
                    <div class="mb-3">
                        <label for="username" class="form-label">Username</label>
                        <input type="text" class="form-control" id="username" name="username" required>
                    </div>
                    <div class="mb-3">
                        <label for="password" class="form-label">Password</label>
                        <input type="password" class="form-control" id="password" name="password" required>
                    </div>
                    <button type="submit" class="btn btn-success w-100">Login</button>
                </form>
                <p class="mt-3 text-center">Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
            </div>
        </div>
    </div>
</div>
{% endblock %}
{% extends 'base.html' %}
{% block title %}Register - Trekking Management{% endblock %}
{% block content %}
<div class="row justify-content-center">
    <div class="col-md-6">
        <div class="card">
            <div class="card-header bg-success text-white">
                <h4 class="mb-0">Register</h4>
            </div>
            <div class="card-body">
                <form method="POST">
                    <div class="mb-3">
                        <label for="username" class="form-label">Username</label>
                        <input type="text" class="form-control" id="username" name="username" required>
                    </div>
                    <div class="mb-3">
                        <label for="email" class="form-label">Email</label>
                        <input type="email" class="form-control" id="email" name="email" required>
                    </div>
                    <div class="mb-3">
                        <label for="password" class="form-label">Password</label>
                        <input type="password" class="form-control" id="password" name="password" required>
                    </div>
                    <div class="mb-3">
                        <label for="role" class="form-label">Register as</label>
                        <select class="form-select" id="role" name="role" required>
                            <option value="user">Trekker (User)</option>
                            <option value="staff">Trek Staff</option>
                        </select>
                    </div>
                    <div class="mb-3">
                        <label for="name" class="form-label">Full Name</label>
                        <input type="text" class="form-control" id="name" name="name" required>
                    </div>
                    <div class="mb-3">
                        <label for="contact" class="form-label">Contact Details</label>
                        <input type="text" class="form-control" id="contact" name="contact" placeholder="Phone or email (required for staff)">
                    </div>
                    <button type="submit" class="btn btn-success w-100">Register</button>
                </form>
                <p class="mt-3 text-center">Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
            </div>
        </div>
    </div>
</div>
{% endblock %}
Create dashboard templates

Each role sees a different dashboard. The admin dashboard shows platform statistics and a staff management page. Staff see their assigned treks. Trekkers see available treks and their booking history.

  • Open admin_dashboard.html inside the templates folder.
  • Replace the empty file with the following code:
{% extends 'base.html' %}
{% block title %}Admin Dashboard - Trekking Management{% endblock %}
{% block content %}
<h2>Admin Dashboard</h2>
<!-- Four stat cards showing platform totals -->
<div class="row mt-4">
    <div class="col-md-3">
        <div class="card text-white bg-primary mb-3">
            <div class="card-body text-center">
                <h5 class="card-title">Total Treks</h5>
                <h2>{{ total_treks }}</h2>
            </div>
        </div>
    </div>
    <div class="col-md-3">
        <div class="card text-white bg-success mb-3">
            <div class="card-body text-center">
                <h5 class="card-title">Total Users</h5>
                <h2>{{ total_users }}</h2>
            </div>
        </div>
    </div>
    <div class="col-md-3">
        <div class="card text-white bg-info mb-3">
            <div class="card-body text-center">
                <h5 class="card-title">Total Staff</h5>
                <h2>{{ total_staff }}</h2>
            </div>
        </div>
    </div>
    <div class="col-md-3">
        <div class="card text-white bg-warning mb-3">
            <div class="card-body text-center">
                <h5 class="card-title">Total Bookings</h5>
                <h2>{{ total_bookings }}</h2>
            </div>
        </div>
    </div>
</div>

<!-- Alert banner when staff are awaiting approval -->
{% if pending_staff > 0 %}
<div class="alert alert-info mt-3">
    <strong>{{ pending_staff }}</strong> staff member(s) pending approval.
    <a href="{{ url_for('admin_manage_staff') }}" class="alert-link">Manage Staff</a>
</div>
{% endif %}

<!-- Quick action links for admin tasks -->
<div class="mt-4">
    <h4>Quick Actions</h4>
    <a href="{{ url_for('admin_manage_staff') }}" class="btn btn-outline-primary me-2">Manage Staff</a>
</div>
{% endblock %}

What does this template do?

The four Bootstrap cards display the counts your admin_dashboard route passes in (total_treks, total_users, total_staff, total_bookings). Each card uses a different Bootstrap background color class.

The {% if pending_staff > 0 %} block conditionally shows an alert only when staff are waiting for approval. This gives the admin an immediate visual cue.

  • Save admin_dashboard.html.
  • Now open admin_manage_staff.html and replace the empty file with this code:
{% extends 'base.html' %}
{% block title %}Manage Staff - Trekking Management{% endblock %}
{% block content %}
<h2>Manage Trek Staff</h2>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary mb-3">Back to Dashboard</a>

<!-- Staff table with status badges and action buttons -->
{% if staff_profiles %}
<table class="table table-striped">
    <thead>
        <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Username</th>
            <th>Contact</th>
            <th>Status</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody>
        {% for staff in staff_profiles %}
        <tr>
            <td>{{ staff.id }}</td>
            <td>{{ staff.name }}</td>
            <td>{{ staff.user.username }}</td>
            <td>{{ staff.contact }}</td>
            <td>
                {% if staff.status == 'pending' %}
                <span class="badge bg-warning">Pending</span>
                {% elif staff.status == 'approved' %}
                <span class="badge bg-success">Approved</span>
                {% else %}
                <span class="badge bg-danger">Blacklisted</span>
                {% endif %}
            </td>
            <td>
                {% if staff.status == 'pending' %}
                <a href="{{ url_for('approve_staff', staff_id=staff.id) }}" class="btn btn-sm btn-success">Approve</a>
                <a href="{{ url_for('blacklist_staff', staff_id=staff.id) }}" class="btn btn-sm btn-danger">Blacklist</a>
                {% elif staff.status == 'approved' %}
                <a href="{{ url_for('blacklist_staff', staff_id=staff.id) }}" class="btn btn-sm btn-danger">Blacklist</a>
                {% endif %}
            </td>
        </tr>
        {% endfor %}
    </tbody>
</table>
{% else %}
<p class="text-muted">No staff members registered yet.</p>
{% endif %}
{% endblock %}

How do the action buttons work?

Each Approve and Blacklist button is a link to a route that takes the staff ID as a URL parameter (e.g. /admin/approve-staff/1). The route updates the database and redirects back to this same page. The status badges use conditional Jinja2 logic to pick the right Bootstrap color.

  • Save admin_manage_staff.html.
  • Now open staff_dashboard.html and replace the empty file with this code:
{% extends 'base.html' %}
{% block title %}Staff Dashboard - Trekking Management{% endblock %}
{% block content %}
<h2>Staff Dashboard</h2>
<p>Welcome, {{ current_user.staff_profile.name }}!</p>

<!-- Table of treks assigned to this staff member -->
<h4 class="mt-4">Your Assigned Treks</h4>
{% if assigned_treks %}
<table class="table table-striped">
    <thead>
        <tr>
            <th>Trek Name</th>
            <th>Location</th>
            <th>Difficulty</th>
            <th>Status</th>
            <th>Available Slots</th>
        </tr>
    </thead>
    <tbody>
        {% for trek in assigned_treks %}
        <tr>
            <td>{{ trek.name }}</td>
            <td>{{ trek.location }}</td>
            <td>{{ trek.difficulty }}</td>
            <td>{{ trek.status }}</td>
            <td>{{ trek.available_slots }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>
{% else %}
<p class="text-muted">No treks assigned to you yet. Please wait for admin to assign treks.</p>
{% endif %}
{% endblock %}
  • Save staff_dashboard.html.
  • Finally, open user_dashboard.html and replace the empty file with this code:
{% extends 'base.html' %}
{% block title %}User Dashboard - Trekking Management{% endblock %}
{% block content %}
<h2>Trekker Dashboard</h2>
<p>Welcome, {{ current_user.username }}!</p>

<!-- Available treks displayed as Bootstrap cards -->
<h4 class="mt-4">Available Treks</h4>
{% if available_treks %}
<div class="row">
    {% for trek in available_treks %}
    <div class="col-md-4 mb-3">
        <div class="card">
            <div class="card-body">
                <h5 class="card-title">{{ trek.name }}</h5>
                <p class="card-text">
                    Location: {{ trek.location }}<br>
                    Difficulty: {{ trek.difficulty }}<br>
                    Duration: {{ trek.duration }} days<br>
                    Available Slots: {{ trek.available_slots }}
                </p>
            </div>
        </div>
    </div>
    {% endfor %}
</div>
{% else %}
<p class="text-muted">No open treks available at the moment.</p>
{% endif %}

<!-- Booking history table -->
<h4 class="mt-4">My Bookings</h4>
{% if my_bookings %}
<table class="table table-striped">
    <thead>
        <tr>
            <th>Trek</th>
            <th>Booking Date</th>
            <th>Status</th>
        </tr>
    </thead>
    <tbody>
        {% for booking in my_bookings %}
        <tr>
            <td>{{ booking.trek.name }}</td>
            <td>{{ booking.booking_date.strftime('%Y-%m-%d') }}</td>
            <td>{{ booking.status }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>
{% else %}
<p class="text-muted">You haven't booked any treks yet.</p>
{% endif %}
{% endblock %}

Why cards for treks and a table for bookings?

Cards work well for browseable items like available treks because each card can show a visual summary. Tables are better for structured records like booking history where the user scans rows for dates and statuses.

Both sections include an {% else %} block that displays a friendly message when there is no data yet. This prevents the page from looking broken on first login.

  • Save user_dashboard.html.

Template not found error when the app runs?

Flask looks for templates in a folder literally named templates (plural). Make sure the folder name is exact and that each file is directly inside it (not nested in a subfolder).

Also verify that your filenames match the strings in render_template() calls in app.py. For example, the route calls render_template('admin_dashboard.html') so the file must be named exactly admin_dashboard.html.

Help me fix a TemplateNotFound error in my Flask app

✔️ Awesome, I've got everything!

Make sure all four dashboard templates are saved: admin_dashboard.html, admin_manage_staff.html, staff_dashboard.html, and user_dashboard.html.

ⓧ I'd like to double check the full code

{% extends 'base.html' %}
{% block title %}Admin Dashboard - Trekking Management{% endblock %}
{% block content %}
<h2>Admin Dashboard</h2>
<div class="row mt-4">
    <div class="col-md-3">
        <div class="card text-white bg-primary mb-3">
            <div class="card-body text-center">
                <h5 class="card-title">Total Treks</h5>
                <h2>{{ total_treks }}</h2>
            </div>
        </div>
    </div>
    <div class="col-md-3">
        <div class="card text-white bg-success mb-3">
            <div class="card-body text-center">
                <h5 class="card-title">Total Users</h5>
                <h2>{{ total_users }}</h2>
            </div>
        </div>
    </div>
    <div class="col-md-3">
        <div class="card text-white bg-info mb-3">
            <div class="card-body text-center">
                <h5 class="card-title">Total Staff</h5>
                <h2>{{ total_staff }}</h2>
            </div>
        </div>
    </div>
    <div class="col-md-3">
        <div class="card text-white bg-warning mb-3">
            <div class="card-body text-center">
                <h5 class="card-title">Total Bookings</h5>
                <h2>{{ total_bookings }}</h2>
            </div>
        </div>
    </div>
</div>

{% if pending_staff > 0 %}
<div class="alert alert-info mt-3">
    <strong>{{ pending_staff }}</strong> staff member(s) pending approval.
    <a href="{{ url_for('admin_manage_staff') }}" class="alert-link">Manage Staff</a>
</div>
{% endif %}

<div class="mt-4">
    <h4>Quick Actions</h4>
    <a href="{{ url_for('admin_manage_staff') }}" class="btn btn-outline-primary me-2">Manage Staff</a>
</div>
{% endblock %}
{% extends 'base.html' %}
{% block title %}Manage Staff - Trekking Management{% endblock %}
{% block content %}
<h2>Manage Trek Staff</h2>
<a href="{{ url_for('admin_dashboard') }}" class="btn btn-secondary mb-3">Back to Dashboard</a>

{% if staff_profiles %}
<table class="table table-striped">
    <thead>
        <tr>
            <th>ID</th>
            <th>Name</th>
            <th>Username</th>
            <th>Contact</th>
            <th>Status</th>
            <th>Actions</th>
        </tr>
    </thead>
    <tbody>
        {% for staff in staff_profiles %}
        <tr>
            <td>{{ staff.id }}</td>
            <td>{{ staff.name }}</td>
            <td>{{ staff.user.username }}</td>
            <td>{{ staff.contact }}</td>
            <td>
                {% if staff.status == 'pending' %}
                <span class="badge bg-warning">Pending</span>
                {% elif staff.status == 'approved' %}
                <span class="badge bg-success">Approved</span>
                {% else %}
                <span class="badge bg-danger">Blacklisted</span>
                {% endif %}
            </td>
            <td>
                {% if staff.status == 'pending' %}
                <a href="{{ url_for('approve_staff', staff_id=staff.id) }}" class="btn btn-sm btn-success">Approve</a>
                <a href="{{ url_for('blacklist_staff', staff_id=staff.id) }}" class="btn btn-sm btn-danger">Blacklist</a>
                {% elif staff.status == 'approved' %}
                <a href="{{ url_for('blacklist_staff', staff_id=staff.id) }}" class="btn btn-sm btn-danger">Blacklist</a>
                {% endif %}
            </td>
        </tr>
        {% endfor %}
    </tbody>
</table>
{% else %}
<p class="text-muted">No staff members registered yet.</p>
{% endif %}
{% endblock %}
{% extends 'base.html' %}
{% block title %}Staff Dashboard - Trekking Management{% endblock %}
{% block content %}
<h2>Staff Dashboard</h2>
<p>Welcome, {{ current_user.staff_profile.name }}!</p>

<h4 class="mt-4">Your Assigned Treks</h4>
{% if assigned_treks %}
<table class="table table-striped">
    <thead>
        <tr>
            <th>Trek Name</th>
            <th>Location</th>
            <th>Difficulty</th>
            <th>Status</th>
            <th>Available Slots</th>
        </tr>
    </thead>
    <tbody>
        {% for trek in assigned_treks %}
        <tr>
            <td>{{ trek.name }}</td>
            <td>{{ trek.location }}</td>
            <td>{{ trek.difficulty }}</td>
            <td>{{ trek.status }}</td>
            <td>{{ trek.available_slots }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>
{% else %}
<p class="text-muted">No treks assigned to you yet. Please wait for admin to assign treks.</p>
{% endif %}
{% endblock %}
{% extends 'base.html' %}
{% block title %}User Dashboard - Trekking Management{% endblock %}
{% block content %}
<h2>Trekker Dashboard</h2>
<p>Welcome, {{ current_user.username }}!</p>

<h4 class="mt-4">Available Treks</h4>
{% if available_treks %}
<div class="row">
    {% for trek in available_treks %}
    <div class="col-md-4 mb-3">
        <div class="card">
            <div class="card-body">
                <h5 class="card-title">{{ trek.name }}</h5>
                <p class="card-text">
                    Location: {{ trek.location }}<br>
                    Difficulty: {{ trek.difficulty }}<br>
                    Duration: {{ trek.duration }} days<br>
                    Available Slots: {{ trek.available_slots }}
                </p>
            </div>
        </div>
    </div>
    {% endfor %}
</div>
{% else %}
<p class="text-muted">No open treks available at the moment.</p>
{% endif %}

<h4 class="mt-4">My Bookings</h4>
{% if my_bookings %}
<table class="table table-striped">
    <thead>
        <tr>
            <th>Trek</th>
            <th>Booking Date</th>
            <th>Status</th>
        </tr>
    </thead>
    <tbody>
        {% for booking in my_bookings %}
        <tr>
            <td>{{ booking.trek.name }}</td>
            <td>{{ booking.booking_date.strftime('%Y-%m-%d') }}</td>
            <td>{{ booking.status }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>
{% else %}
<p class="text-muted">You haven't booked any treks yet.</p>
{% endif %}
{% endblock %}

All seven templates are ready. Next up, you will run the application and test every feature end-to-end to see these templates in action.

Run and Test the Application

Your templates are complete and connected to the routes in app.py. The entire application is ready to launch.

In this step, you will start the Flask development server and test every role-based workflow end-to-end. This confirms that authentication, access control decorators, and the admin approval system all work together.

In this step, get ready to:
  • Start the Flask development server and verify the database is created.
  • Test the admin login, dashboard, and staff management page.
  • Test staff registration, pending approval block, admin approval, and blacklisting.
Run the application

The init_db() function in app.py runs automatically when you start the server. It creates the SQLite database, builds all four tables, and seeds the admin user.

  • In the VS Code terminal (with your virtual environment activated), start the Flask server by running:
python app.py

You should see output indicating the server is running on http://127.0.0.1:5000.

  • Open your browser and navigate to http://127.0.0.1:5000.
  • You should be automatically redirected to the Login page with a green header and username/password fields.
  • Back in VS Code, check the file explorer sidebar. You should see a new instance folder containing trekking.db.

Why is the database inside an instance folder?

Flask-SQLAlchemy 3.1.1 stores relative SQLite paths inside Flask's instance_path directory. This keeps your database file separate from your source code, which is a best practice for deployable applications.

Server not starting?

Make sure your virtual environment is activated. You should see (venv) at the beginning of your terminal prompt.

If you see a ModuleNotFoundError, the packages are not installed in your active environment. Re-run the pip install command from Step 1.

If you see a SyntaxError, double-check that your app.py and models.py files are saved and match the code from the previous steps.

Help me debug why my Flask server won't start.

Test the admin workflow

The init_db() function pre-seeded an admin account. You will log in with it to verify the dashboard and staff management page load correctly.

  • On the Login page, enter admin in the **Username** field.
  • Enter admin123 in the **Password** field.
  • Click the **Login** button.

You should see the Admin Dashboard with four colored statistic cards (Total Treks, Total Users, Total Staff, Total Bookings). All values show 0 because the database is fresh.

  • Click the **Manage Staff** button in the Quick Actions section.

You should see the Manage Trek Staff page with the message "No staff members registered yet." because no one has registered as staff.

  • Click **Logout** in the top navigation bar.

You should be redirected back to the Login page with a green "Logged out successfully" alert.

Seeing a blank page or error after login?

Check that all seven template files are saved inside the templates folder (not in the project root). Flask looks for templates in a folder named exactly templates.

Help me figure out why my admin dashboard is not loading.

Test staff registration and approval

The core value of this application is its approval workflow. Staff cannot access their dashboard until an admin explicitly approves them. You will walk through this entire cycle now.

  • On the Login page, click the **Register here** link at the bottom.
  • Enter staffuser1 in the **Username** field.
  • Enter staff1@test.com in the **Email** field.
  • Enter password123 in the **Password** field.
  • Select **Trek Staff** from the **Register as** dropdown.
  • Enter Test Staff in the **Full Name** field.
  • Enter 1234567890 in the **Contact Details** field.
  • Click the **Register** button.

You should see a blue info alert saying "Registration successful! Please wait for admin approval." and be redirected to the Login page.

  • Try logging in with username staffuser1 and password password123.

You should see a yellow warning alert: "Your account is pending admin approval." The staff member is blocked from accessing their dashboard.

  • Log in as admin again (username admin, password admin123).
  • Click **Manage Staff** in the Quick Actions section.

You should see the staff table with your new staff member listed. Their status shows a yellow "Pending" badge.

  • Click the green **Approve** button next to the staff member.

You should see a success message confirming the staff was approved. The badge changes to green "Approved".

  • Click **Logout**.
  • Log in as the staff member (username staffuser1, password password123).

You should now see the Staff Dashboard with the welcome message and an empty assigned treks table.

  • Click **Logout** and log back in as admin.
  • Go to **Manage Staff** and click the red **Blacklist** button next to the staff member.
  • Click **Logout** and try logging in as staffuser1 again.

You should see a red danger alert: "Your account has been blacklisted. Contact admin." The blacklisted staff member cannot log in.

What about trekker registration?

You can also test registering a user with the "Trekker (User)" role. Trekkers do not need admin approval. After registering, they can log in immediately and see the Trekker Dashboard with empty trek cards and booking history.

Staff login still blocked after approval?

Make sure you clicked **Approve** (green button) and not **Blacklist** (red button). Check the Manage Staff page to confirm the badge shows "Approved" in green.

If the badge shows "Approved" but login still fails, verify the username and password are typed exactly as you entered during registration.

Help me debug why my approved staff member still cannot log in.

Secret mission

Add User Management for Admin

Your admin can manage staff, but what about regular trekkers? Add a User Management page where Admin can view all registered trekkers and blacklist or unblacklist them. This mirrors the staff management feature but targets regular users.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources for future building, or delete them entirely.

Resources you used:

  • The trekking_app project folder on your Desktop (contains all source code and templates).
  • A Python virtual environment folder venv inside the project.
  • A SQLite database file at instance/trekking.db (automatically created, no cost).

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue with a follow-up project that adds Trek Management and Booking features.

  • Your Flask server will stop when you close the terminal or press Ctrl+C. You can restart it anytime with python app.py.
  • All your files, database, and virtual environment remain in the trekking_app folder.
  • There are no cloud resources or subscriptions to manage.

Pause - I'll come back to this later

Stop the running Flask server to free up your terminal. Your files stay on disk and are ready whenever you return.

  • Switch to the terminal where the Flask server is running.
  • Press Ctrl+C to stop the development server.
  • You can close VS Code. Your project folder and database will be waiting for you.
  • When you return, open the trekking_app folder in VS Code, activate the virtual environment, and run python app.py again.

Delete - I don't want to use this again

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

  • Stop the Flask server by pressing Ctrl+C in the terminal.
  • Deactivate your virtual environment by running:
deactivate
  • Navigate to your Desktop and delete the entire project folder by running:
cd ~/Desktop
rm -rf trekking_app

On Windows, run this instead:

cd ~\Desktop
rmdir /s /q trekking_app
  • Confirm the folder is gone by running:
ls

You should no longer see trekking_app in the output.

Nice Work!

Nice Work!

Nice work! You just built a complete authentication and role management foundation for a Trekking Management Application using Flask.

You've learned how to:

  • Build a Flask web application with role-based authentication supporting three distinct user roles (Admin, Trek Staff, Trekker) with custom access decorators.
  • Create four database models programmatically with Flask-SQLAlchemy and SQLite, including foreign key relationships and automatic table creation.
  • Implement an Admin dashboard with system statistics and a staff approval/blacklisting workflow that controls who can access the platform.
  • Secret Mission: Added a User Management page allowing Admin to blacklist and unblacklist trekkers.

Ready to quiz yourself?