Trekking App: Foundation and Auth
Build a Flask app with role-based authentication and a SQLite database.
Introduction
30 Second Summary
Think about booking a hiking trip online. Behind that simple "Book Now" button is a system that knows who you are, what you're allowed to do, and which trails are available.
In this project, you will build the foundation of a trekking management app using Python and Flask. You'll design a database, create a registration and login system, and set up three different user roles that each see their own dashboard.
What You'll Build
A Flask web application with an SQLite database, session-based authentication, and role-based dashboards for Admin, Staff, and User accounts.
By the end of this project, you'll have:
- A Flask application using the factory pattern with two blueprints for organized, professional code.
- An SQLite database with models for Users, Treks, Bookings, and Staff Assignments created programmatically.
- A complete authentication system with registration, login, password hashing, and role-based dashboard routing.
- Secret Mission: Add a profile editing page where users can update their name and email.
Are there any prerequisites?
You need Python and VS Code installed on your computer. No prior Python or Flask experience is required.
This is Project 1 of a 4-part series that will grow into a complete trekking platform. Each project builds on the previous one.
Set Up the Project
Every web application starts with a solid foundation. Before you write any features, you need a clean project structure that keeps your code organized as it grows.
In this step, you will install Flask and its extensions into an isolated environment. Then you will create a package-based project layout using the application factory pattern, which is the professional way to structure Flask apps.
In this step, get ready to:
- Install Flask, Flask-Login, and Flask-SQLAlchemy in a virtual environment.
- Create a package-based project structure with blueprints.
- Implement the application factory pattern and run the dev server.
Create your project folder and install Flask
A virtual environment is a self-contained Python installation. It keeps your project's packages separate from your system Python so different projects never conflict.
- 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 and activate a Python virtual environment by running the commands for your operating system:
macOS
python3 -m venv .venv
source .venv/bin/activate
Windows
py -3 -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.
- Install Flask and its extensions by running:
pip install Flask Flask-Login Flask-SQLAlchemy
What do these packages do?
- Flask is the web framework that handles routing, templates, and the development server.
- Flask-Login manages user sessions so the app remembers who is logged in.
- Flask-SQLAlchemy connects Flask to a database using Python classes instead of raw SQL.
- Verify the installation succeeded by running:
flask --version
- You should see output showing Flask 3.1.3 along with the Python and Werkzeug versions.
Not seeing a version number?
- Make sure (.venv) appears in your terminal prompt. If it does not, re-run the activation command for your OS.
- If pip install failed, check that your virtual environment was created with Python 3.11. Run python3 --version to confirm.
Having trouble? Help me troubleshoot my Flask installation.
Create the package-based project structure
Flask apps can live in a single file, but professional projects use a package layout. A package is just a folder containing an __init__.py file, which lets Python treat it as a module.
- Create the folder structure by running the commands for your operating system:
macOS
mkdir -p trekking_app/templates trekking_app/static
touch trekking_app/__init__.py trekking_app/models.py trekking_app/auth.py trekking_app/main.py
touch run.py
Windows
mkdir trekking_app\templates, trekking_app\static
New-Item trekking_app\__init__.py, trekking_app\models.py, trekking_app\auth.py, trekking_app\main.py, run.py -ItemType File
- Confirm the files exist by running:
ls trekking_app
- You should see __init__.py, auth.py, main.py, models.py, static, and templates in the output.
What is this structure for?
- trekking_app/ is the Python package containing all application code.
- __init__.py is the factory that creates and configures the Flask app.
- auth.py and main.py are blueprints that organize routes into logical groups.
- models.py defines your database tables as Python classes.
- run.py is the entry point that the Flask CLI uses to start the server.
- Now open this project folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the trekking-app folder on your Desktop, then click Open.
You should see the trekking_app folder and run.py in the VS Code Explorer sidebar on the left.
Don't see the files in VS Code?
- Make sure you opened the trekking-app folder itself, not the Desktop or a parent folder.
- If the Explorer sidebar is hidden, press Cmd+Shift+E (macOS) or Ctrl+Shift+E (Windows) to reveal it.
Help me find my project files in VS Code.
Implement the application factory
The application factory pattern wraps your Flask app creation inside a function called create_app(). This makes your app easier to test and avoids circular import problems as your codebase grows.
- In the VS Code Explorer, click run.py to open it in the editor.
- Paste the following code into run.py:
from trekking_app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True)
What does this file do?
This is the entry point for your application. It imports the create_app factory function from your trekking_app package and calls it to build the Flask app. The Flask CLI looks for this file when you run the server.
- Save run.py.
- Now open trekking_app/__init__.py by clicking it in the Explorer sidebar.
- Paste the following module-level setup code at the top of the file:
import os
import click
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
login_manager = LoginManager()
What does this code do?
- Base is the parent class for all your database models. SQLAlchemy 2.x uses this pattern for type-safe column definitions.
- db is the SQLAlchemy extension instance. You will use it to define models and run queries.
- login_manager is the Flask-Login extension that tracks who is logged in across requests.
These are created at module level (outside any function) so that other files like models.py can import them directly.
- Directly below the login_manager line, paste the create_app factory function:
def create_app():
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY='dev-secret-key-change-in-production',
SQLALCHEMY_DATABASE_URI='sqlite:///trekking.db',
)
os.makedirs(app.instance_path, exist_ok=True)
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
from . import models
@login_manager.user_loader
def load_user(user_id):
return db.session.get(models.User, int(user_id))
from .auth import auth_bp
from .main import main_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
return app
What does create_app do?
- instance_relative_config=True tells Flask to store instance-specific files (like the database) in a separate instance/ folder.
- SECRET_KEY signs session cookies to prevent tampering. The dev key here is a placeholder for local development only.
- SQLALCHEMY_DATABASE_URI points to an SQLite database file that will live inside the instance folder.
- login_manager.login_view = 'auth.login' tells Flask-Login which route to redirect unauthenticated users to.
- The user_loader callback tells Flask-Login how to reload a user from the database using their session ID.
- The two register_blueprint calls attach the auth and main route groups to the app.
What is the application factory pattern?
Instead of creating a global app = Flask(__name__) at the top of a file, you wrap the setup in a function. Flask auto-detects any function named create_app when you run the CLI. This pattern prevents circular imports and lets you create multiple app instances for testing.
- Save trekking_app/__init__.py.
- The factory imports auth_bp and main_bp from the blueprint files. You need to define those names so the import does not fail. Open trekking_app/auth.py and paste:
from flask import Blueprint
auth_bp = Blueprint('auth', __name__)
- Save auth.py. Now open trekking_app/main.py and paste:
from flask import Blueprint
main_bp = Blueprint('main', __name__)
What are blueprints?
A blueprint is a way to group related routes together. Instead of defining every URL in one giant file, you split them into logical modules. The auth blueprint will hold login and registration routes. The main blueprint will hold dashboard routes.
- Save main.py.
- Open the integrated terminal in VS Code by pressing Ctrl+`. Make sure (.venv) still appears in the prompt.
- Start the Flask development server by running:
flask --app run run --debug
- You should see output including Running on http://127.0.0.1:5000 and Debug mode: on.
What does this command mean?
The first run after --app tells Flask to look in your run.py file for the app. Flask auto-detects the create_app factory function inside it. The second run is the Flask CLI subcommand that starts the development server.
The --debug flag enables auto-reload on code changes so you do not have to restart the server manually.
Seeing an ImportError?
- If you see cannot import name 'auth_bp', make sure you saved trekking_app/auth.py with the auth_bp = Blueprint('auth', __name__) line.
- If you see cannot import name 'main_bp', check trekking_app/main.py for the matching main_bp definition.
- Make sure your terminal is inside the trekking-app folder (the one containing run.py), not inside trekking_app/.
Help me fix my Flask app startup error.
- Stop the server by pressing Ctrl+C in the terminal.
✔️ Awesome, I've got everything!
Great. Double check that all your files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete code for each file at this point:
from trekking_app import create_app
app = create_app()
if __name__ == '__main__':
app.run(debug=True)
import os
import click
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
login_manager = LoginManager()
def create_app():
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY='dev-secret-key-change-in-production',
SQLALCHEMY_DATABASE_URI='sqlite:///trekking.db',
)
os.makedirs(app.instance_path, exist_ok=True)
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
from . import models
@login_manager.user_loader
def load_user(user_id):
return db.session.get(models.User, int(user_id))
from .auth import auth_bp
from .main import main_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
return app
from flask import Blueprint
auth_bp = Blueprint('auth', __name__)
from flask import Blueprint
main_bp = Blueprint('main', __name__)
Your project skeleton is in place with a running Flask server. Next up, you will fill in the database models that power your trekking app's users, treks, and bookings.
Design and Create the Database
Your Flask app is running and the project structure is in place. Now it needs a database to store users, treks, and bookings.
In this step, you will define SQLAlchemy models that describe the shape of your data. Then you will create a CLI command that builds the actual database tables and seeds an admin account.
In this step, get ready to:
- Define four database models in models.py: User, Trek, Booking, and StaffAssignment.
- Create a Flask CLI command that builds all tables and seeds an admin superuser.
- Run the command and verify the database exists with the correct tables and data.
Define the database models
Each model is a Python class that maps to a database table. The columns you define on the class become columns in the table.
- In VS Code, open trekking_app/models.py and add the User model with its imports by pasting this code:
from datetime import datetime, timezone
from flask_login import UserMixin
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Text
from . import db
class User(UserMixin, db.Model):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default='user')
is_approved: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
What does this code do?
The imports pull in SQLAlchemy's type-annotation tools (Mapped, mapped_column) and UserMixin from Flask-Login.
- UserMixin gives the User class authentication helpers like is_authenticated and get_id() that Flask-Login expects.
- The role field stores 'admin', 'staff', or 'user' to control what each person can access.
- The is_approved flag defaults to True for regular users but will be set to False for staff who need admin approval before gaining access.
You won't see a visible change yet. The next chunk adds the remaining models to the same file.
- Below the User class in models.py, add the Trek, Booking, and StaffAssignment models:
class Trek(db.Model):
__tablename__ = 'treks'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
location: Mapped[str] = mapped_column(String(200), nullable=False)
difficulty: Mapped[str] = mapped_column(String(20), nullable=False)
total_slots: Mapped[int] = mapped_column(Integer, nullable=False)
available_slots: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(20), default='open')
description: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
class Booking(db.Model):
__tablename__ = 'bookings'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('users.id'), nullable=False)
trek_id: Mapped[int] = mapped_column(Integer, ForeignKey('treks.id'), nullable=False)
booking_date: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
status: Mapped[str] = mapped_column(String(20), default='confirmed')
class StaffAssignment(db.Model):
__tablename__ = 'staff_assignments'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
staff_id: Mapped[int] = mapped_column(Integer, ForeignKey('users.id'), nullable=False)
trek_id: Mapped[int] = mapped_column(Integer, ForeignKey('treks.id'), nullable=False)
assigned_date: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
What do these models represent?
- Trek stores each trekking route with its difficulty, capacity (total_slots and available_slots), and status.
- Booking connects a user to a trek they have booked. The ForeignKey fields link back to the users and treks tables.
- StaffAssignment connects a staff member to the trek they manage. This is how the admin assigns staff to specific routes.
- Save models.py.
You won't see a visible change yet. These models come to life in the next substep when you create the database tables.
Seeing a red underline or import error?
- Make sure the first line of models.py is from . import db. The dot means "import from this package" and only works because trekking_app/ has an __init__.py.
- Check that your virtual environment is active. VS Code sometimes selects the wrong Python interpreter.
Ask for help: Why is VS Code showing an import error in my Flask models file?
✔️ Awesome, I've got everything!
Great. Double check you have saved models.py before moving on.
ⓧ I'd like to double check the full code
from datetime import datetime, timezone
from flask_login import UserMixin
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Integer, Boolean, DateTime, ForeignKey, Text
from . import db
class User(UserMixin, db.Model):
__tablename__ = 'users'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
username: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
email: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
role: Mapped[str] = mapped_column(String(20), nullable=False, default='user')
is_approved: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
class Trek(db.Model):
__tablename__ = 'treks'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(200), nullable=False)
location: Mapped[str] = mapped_column(String(200), nullable=False)
difficulty: Mapped[str] = mapped_column(String(20), nullable=False)
total_slots: Mapped[int] = mapped_column(Integer, nullable=False)
available_slots: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[str] = mapped_column(String(20), default='open')
description: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
class Booking(db.Model):
__tablename__ = 'bookings'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey('users.id'), nullable=False)
trek_id: Mapped[int] = mapped_column(Integer, ForeignKey('treks.id'), nullable=False)
booking_date: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
status: Mapped[str] = mapped_column(String(20), default='confirmed')
class StaffAssignment(db.Model):
__tablename__ = 'staff_assignments'
id: Mapped[int] = mapped_column(Integer, primary_key=True)
staff_id: Mapped[int] = mapped_column(Integer, ForeignKey('users.id'), nullable=False)
trek_id: Mapped[int] = mapped_column(Integer, ForeignKey('treks.id'), nullable=False)
assigned_date: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
Create the init-db CLI command
Models describe the shape of your data, but they do not create the actual database file. You need a command that reads the models and builds the corresponding SQLite tables.
- In trekking_app/__init__.py, find the line that says return app at the bottom of the create_app() function.
- Add the following code directly above return app:
@app.cli.command('init-db')
def init_db_command():
db.create_all()
from werkzeug.security import generate_password_hash
admin = db.session.execute(
db.select(models.User).filter_by(role='admin')
).scalar_one_or_none()
if admin is None:
admin = models.User(
username='admin',
email='admin@trekking.com',
password_hash=generate_password_hash('admin123'),
role='admin',
is_approved=True,
)
db.session.add(admin)
db.session.commit()
click.echo('Database created. Admin user seeded.')
else:
click.echo('Database created. Admin already exists.')
What does this command do?
- @app.cli.command('init-db') registers a custom Flask CLI command you can run from the terminal.
- db.create_all() reads all your model classes and creates the matching tables in the SQLite database file.
- The rest checks whether an admin user already exists. If not, it creates one with a hashed password so you have a superuser ready for testing.
- generate_password_hash() from Werkzeug ensures the password is never stored as plain text.
- Save __init__.py.
Seeing an indentation error?
- The entire @app.cli.command block must be indented inside create_app(), at the same level as return app. That is one level of indentation (4 spaces) from the left margin.
- Make sure return app is still the very last line of the function, after the new command block.
Ask for help: My Flask app crashes after I added the init-db command. How do I fix the indentation?
✔️ Awesome, I've got everything!
Great. Double check you have saved __init__.py before moving on.
ⓧ I'd like to double check the full code
import os
import click
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
login_manager = LoginManager()
def create_app():
app = Flask(__name__, instance_relative_config=True)
app.config.from_mapping(
SECRET_KEY='dev-secret-key-change-in-production',
SQLALCHEMY_DATABASE_URI='sqlite:///trekking.db',
)
os.makedirs(app.instance_path, exist_ok=True)
db.init_app(app)
login_manager.init_app(app)
login_manager.login_view = 'auth.login'
from . import models
@login_manager.user_loader
def load_user(user_id):
return db.session.get(models.User, int(user_id))
from .auth import auth_bp
from .main import main_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@app.cli.command('init-db')
def init_db_command():
db.create_all()
from werkzeug.security import generate_password_hash
admin = db.session.execute(
db.select(models.User).filter_by(role='admin')
).scalar_one_or_none()
if admin is None:
admin = models.User(
username='admin',
email='admin@trekking.com',
password_hash=generate_password_hash('admin123'),
role='admin',
is_approved=True,
)
db.session.add(admin)
db.session.commit()
click.echo('Database created. Admin user seeded.')
else:
click.echo('Database created. Admin already exists.')
return app
Run and verify the database
Time to run the command and confirm everything works. The CLI command will create the SQLite file and insert the admin record.
- In your terminal, run the init-db command:
flask --app run init-db
- You should see this output:
Database created. Admin user seeded.
- Confirm the database file was created by running:
ls instance/
- You should see trekking.db listed in the output.
Where does the database file live?
Flask-SQLAlchemy stores SQLite files in the instance/ folder by default. The sqlite:///trekking.db URI uses three slashes to indicate a path relative to the instance folder, not your project root.
- Verify the admin record exists by opening a Flask shell:
flask --app run shell
- Inside the shell, run this query to check the admin user:
from trekking_app.models import User
from trekking_app import db
admin = db.session.execute(db.select(User).filter_by(role='admin')).scalar_one_or_none()
print(admin.username, admin.email, admin.role)
- You should see admin admin@trekking.com admin printed in the shell.
- Type exit() to leave the Flask shell.
Command not found or import error?
- If you see "No such command 'init-db'", make sure __init__.py is saved and the @app.cli.command('init-db') block is inside create_app(), not outside it.
- If you see an import error mentioning models, confirm models.py is saved inside the trekking_app/ folder (not the project root).
- If you get a "table already exists" error on a second run, that is fine. The command checks for the admin user and prints "Database created. Admin already exists." instead.
Ask for help: The flask init-db command is failing with an error. Help me figure out what went wrong.
Your database is ready with four tables and a pre-seeded admin account. Next up, you will build the authentication system that lets users register, log in, and get routed to their role-specific dashboard.
Build the Authentication System
Your database models are ready and the admin account is seeded. Now you need to let people actually log in and sign up.
In this step, you will build the complete authentication system. This includes registration, login, logout, and a custom decorator that locks routes to specific roles.
In this step, get ready to:
- Create a role_required decorator and protected dashboard routes.
- Build a registration route that handles Users and Staff differently.
- Build login and logout routes with role-based redirects.
Create the role_required decorator and dashboard routes
Flask-Login handles authentication (who is this person?) but deliberately does not handle authorization (what are they allowed to do?). You need a custom decorator that checks the logged-in user's role before granting access to a route.
You will build this decorator in main.py alongside the dashboard routes it protects.
- Open trekking_app/main.py in VS Code and add the imports, blueprint, and role_required decorator:
from functools import wraps
from flask import Blueprint, render_template, redirect, url_for, abort
from flask_login import login_required, current_user
# Create the main blueprint for dashboard routes
main_bp = Blueprint('main', __name__)
# Custom decorator that restricts a route to a specific role
def role_required(role):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if current_user.role != role:
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
What does this code do?
The role_required function is a decorator factory. You pass it a role name like 'admin' and it returns a decorator that checks current_user.role before letting the request through.
If the user's role does not match, abort(403) immediately stops the request with a Forbidden error. The @wraps(f) from functools preserves the original function's name and docstring so Flask's routing works correctly.
Why a custom decorator instead of Flask-Security?
Flask-Login intentionally handles only authentication (verifying identity), not authorization (checking permissions). Libraries like Flask-Security bundle both together, but they hide the logic behind abstractions.
Building role_required manually teaches you how Python decorators work and how role-based access control layers on top of authentication. You can see exactly what happens when an unauthorized user hits a protected route.
- Save main.py. You will not see a visible change yet because the dashboard templates do not exist. The next chunk adds the routes that use this decorator.
- Below the role_required decorator in main.py, add the index route that redirects users based on their role:
@main_bp.route('/')
def index():
# Redirect authenticated users to their role-specific dashboard
if current_user.is_authenticated:
if current_user.role == 'admin':
return redirect(url_for('main.admin_dashboard'))
elif current_user.role == 'staff':
if not current_user.is_approved:
return redirect(url_for('main.staff_pending'))
return redirect(url_for('main.staff_dashboard'))
else:
return redirect(url_for('main.user_dashboard'))
# Unauthenticated visitors go to login
return redirect(url_for('auth.login'))
What does this route do?
The root URL / acts as a traffic controller. It checks whether the visitor is logged in, inspects their role, and sends them to the correct dashboard.
Staff members get an extra check: if is_approved is False, they go to a pending page instead of their dashboard. Anonymous visitors land on the login page.
- Save main.py. You will not see a visible change yet. The next chunk adds the dashboard endpoints this route redirects to.
- Below the index route in main.py, add the four protected dashboard routes:
@main_bp.route('/admin/dashboard')
@login_required
@role_required('admin')
def admin_dashboard():
return render_template('admin_dashboard.html')
@main_bp.route('/staff/dashboard')
@login_required
@role_required('staff')
def staff_dashboard():
# Double-check approval status even if they reach this route directly
if not current_user.is_approved:
return redirect(url_for('main.staff_pending'))
return render_template('staff_dashboard.html')
@main_bp.route('/staff/pending')
@login_required
@role_required('staff')
def staff_pending():
return render_template('staff_pending.html')
@main_bp.route('/user/dashboard')
@login_required
@role_required('user')
def user_dashboard():
return render_template('user_dashboard.html')
What do these routes do?
Each dashboard route is protected by two decorators stacked together. @login_required ensures the user is authenticated. @role_required('admin') ensures they hold the correct role.
The staff_dashboard route includes an extra is_approved check. This handles the case where an unapproved staff member navigates directly to the URL, bypassing the index route's logic.
- Save main.py.
Seeing an import error when the server restarts?
Make sure from flask_login import login_required, current_user is at the top of your file. Both names are needed.
Also check that main_bp = Blueprint('main', __name__) uses exactly the string 'main'. The __init__.py file imports main_bp by that name.
help me fix an import error in my Flask main.py blueprint
✔️ Awesome, I've got everything!
Great. Double check you have saved main.py before moving on.
ⓧ I'd like to double check the full code
from functools import wraps
from flask import Blueprint, render_template, redirect, url_for, abort
from flask_login import login_required, current_user
main_bp = Blueprint('main', __name__)
def role_required(role):
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if current_user.role != role:
abort(403)
return f(*args, **kwargs)
return decorated_function
return decorator
@main_bp.route('/')
def index():
if current_user.is_authenticated:
if current_user.role == 'admin':
return redirect(url_for('main.admin_dashboard'))
elif current_user.role == 'staff':
if not current_user.is_approved:
return redirect(url_for('main.staff_pending'))
return redirect(url_for('main.staff_dashboard'))
else:
return redirect(url_for('main.user_dashboard'))
return redirect(url_for('auth.login'))
@main_bp.route('/admin/dashboard')
@login_required
@role_required('admin')
def admin_dashboard():
return render_template('admin_dashboard.html')
@main_bp.route('/staff/dashboard')
@login_required
@role_required('staff')
def staff_dashboard():
if not current_user.is_approved:
return redirect(url_for('main.staff_pending'))
return render_template('staff_dashboard.html')
@main_bp.route('/staff/pending')
@login_required
@role_required('staff')
def staff_pending():
return render_template('staff_pending.html')
@main_bp.route('/user/dashboard')
@login_required
@role_required('user')
def user_dashboard():
return render_template('user_dashboard.html')
Build the registration route
Registration is where new accounts enter the system. Users get immediate access, but Staff must wait for admin approval. Your registration route needs to handle both cases.
- Open trekking_app/auth.py in VS Code and add the imports, blueprint, and registration route:
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required
from werkzeug.security import generate_password_hash, check_password_hash
from . import db
from .models import User
# Create the auth blueprint for login/register/logout routes
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
# Collect form data
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
role = request.form.get('role', 'user')
# Validate that all fields are filled
if not username or not email or not password:
flash('All fields are required.', 'danger')
return redirect(url_for('auth.register'))
# Only allow 'user' or 'staff' roles via registration
if role not in ('user', 'staff'):
flash('Invalid role selected.', 'danger')
return redirect(url_for('auth.register'))
# Check if username is already taken
existing_user = db.session.execute(
db.select(User).filter_by(username=username)
).scalar_one_or_none()
if existing_user:
flash('Username already exists.', 'danger')
return redirect(url_for('auth.register'))
# Check if email is already registered
existing_email = db.session.execute(
db.select(User).filter_by(email=email)
).scalar_one_or_none()
if existing_email:
flash('Email already registered.', 'danger')
return redirect(url_for('auth.register'))
# Create the new user with hashed password
new_user = User(
username=username,
email=email,
password_hash=generate_password_hash(password),
role=role,
is_approved=(role == 'user'),
)
db.session.add(new_user)
db.session.commit()
# Staff must wait for approval; users can log in immediately
if role == 'staff':
flash('Registration successful! Awaiting admin approval.', 'info')
else:
flash('Registration successful! Please log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('register.html')
What does this route do?
The register route handles both GET (show the form) and POST (process the form). On POST, it validates inputs, checks for duplicate usernames and emails, then creates the user record.
The key line is is_approved=(role == 'user'). This evaluates to True for regular users and False for staff. Staff members cannot access their dashboard until an admin flips this flag.
Passwords are never stored in plain text. generate_password_hash() from Werkzeug hashes the password before saving it to the database.
- Save auth.py. You will not see a browser change yet because the login route is not built. The next chunk adds it.
Server showing a ModuleNotFoundError?
Make sure the relative imports at the top use from . import db and from .models import User with the leading dot. Without the dot, Python looks for top-level modules that do not exist.
help me fix a ModuleNotFoundError in my Flask auth blueprint
Build the login and logout routes
The login route verifies credentials and starts a session. It also decides where to send the user based on their role and approval status.
- In auth.py, add the login route below the register function:
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
# Look up the user by username
user = db.session.execute(
db.select(User).filter_by(username=username)
).scalar_one_or_none()
# Verify credentials
if user is None or not check_password_hash(user.password_hash, password):
flash('Invalid username or password.', 'danger')
return redirect(url_for('auth.login'))
# Start the session
login_user(user)
# Redirect based on role and approval status
if user.role == 'admin':
return redirect(url_for('main.admin_dashboard'))
elif user.role == 'staff':
if not user.is_approved:
return redirect(url_for('main.staff_pending'))
return redirect(url_for('main.staff_dashboard'))
else:
return redirect(url_for('main.user_dashboard'))
return render_template('login.html')
What does this route do?
On POST, the route queries the database for a matching username, then uses check_password_hash() to compare the submitted password against the stored hash. If either check fails, the user sees a generic error message (never reveal which part was wrong for security).
When credentials are valid, login_user(user) from Flask-Login creates a session cookie. The redirect logic mirrors the index route: admins, approved staff, unapproved staff, and regular users each land on a different page.
- Save auth.py. The login route is now in place. The next chunk adds the logout route to complete the auth flow.
- Below the login function in auth.py, add the logout route:
@auth_bp.route('/logout')
@login_required
def logout():
# End the user's session
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
What does this route do?
The logout_user() function from Flask-Login clears the session cookie. The @login_required decorator ensures only logged-in users can hit this route. After logout, the user is redirected back to the login page with a confirmation message.
- Save auth.py.
- Verify that all routes are registered by running this command in your terminal:
flask --app run routes
You should see a table listing endpoints including auth.register, auth.login, auth.logout, main.index, main.admin_dashboard, main.staff_dashboard, main.staff_pending, and main.user_dashboard.
Not seeing all the routes?
If routes from one blueprint are missing, check that __init__.py imports and registers that blueprint. The lines from .auth import auth_bp and from .main import main_bp must both exist.
If you see a syntax error, check for missing colons at the end of def lines or unmatched parentheses in the db.session.execute() calls.
help me debug why flask routes is not showing all my registered blueprints
✔️ Awesome, I've got everything!
Great. Double check you have saved auth.py before moving on.
ⓧ I'd like to double check the full code
from flask import Blueprint, render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, login_required
from werkzeug.security import generate_password_hash, check_password_hash
from . import db
from .models import User
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/register', methods=['GET', 'POST'])
def register():
if request.method == 'POST':
username = request.form.get('username')
email = request.form.get('email')
password = request.form.get('password')
role = request.form.get('role', 'user')
if not username or not email or not password:
flash('All fields are required.', 'danger')
return redirect(url_for('auth.register'))
if role not in ('user', 'staff'):
flash('Invalid role selected.', 'danger')
return redirect(url_for('auth.register'))
existing_user = db.session.execute(
db.select(User).filter_by(username=username)
).scalar_one_or_none()
if existing_user:
flash('Username already exists.', 'danger')
return redirect(url_for('auth.register'))
existing_email = db.session.execute(
db.select(User).filter_by(email=email)
).scalar_one_or_none()
if existing_email:
flash('Email already registered.', 'danger')
return redirect(url_for('auth.register'))
new_user = User(
username=username,
email=email,
password_hash=generate_password_hash(password),
role=role,
is_approved=(role == 'user'),
)
db.session.add(new_user)
db.session.commit()
if role == 'staff':
flash('Registration successful! Awaiting admin approval.', 'info')
else:
flash('Registration successful! Please log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('register.html')
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = db.session.execute(
db.select(User).filter_by(username=username)
).scalar_one_or_none()
if user is None or not check_password_hash(user.password_hash, password):
flash('Invalid username or password.', 'danger')
return redirect(url_for('auth.login'))
login_user(user)
if user.role == 'admin':
return redirect(url_for('main.admin_dashboard'))
elif user.role == 'staff':
if not user.is_approved:
return redirect(url_for('main.staff_pending'))
return redirect(url_for('main.staff_dashboard'))
else:
return redirect(url_for('main.user_dashboard'))
return render_template('login.html')
@auth_bp.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'info')
return redirect(url_for('auth.login'))
Your authentication backbone is complete. Both blueprints are registered and all routes are wired up. Next, you will create the HTML templates so these routes can actually render pages in the browser.
Create Templates with Bootstrap
Your routes are built, your models are defined, and your authentication logic is wired up. There is one problem: every route calls render_template() but no template files exist yet.
In this step, you will create the HTML templates that turn your Flask app into something visible. You will use Bootstrap for styling and Jinja2 template inheritance to keep your HTML DRY.
In this step, get ready to:
- Build a base template with Bootstrap CDN, a responsive navbar, and flash messages.
- Create login and registration form templates with card layouts.
- Create role-specific dashboard templates with placeholder stat cards.
Build the base template and custom styles
Jinja2 template inheritance lets you define a shared layout once and reuse it across every page. The base template holds the navbar, Bootstrap CDN links, and a content block that child templates fill in.
- In VS Code, create a new file called base.html inside the trekking_app/templates/ folder.
- Paste the following code into base.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Trekking App{% endblock %}</title>
<!-- Bootstrap 5.3.8 CSS from CDN -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-sRIl4kxILFvY47J16cr9ZwB07vP4J8+LH7qKQnuqkuIAvNWLzeN8tE5YBujZqJLB" crossorigin="anonymous">
<!-- Custom stylesheet -->
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<!-- Responsive dark navbar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.index') }}">Trekking App</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{% if current_user.is_authenticated %}
<li class="nav-item">
<span class="nav-link text-light">Welcome, {{ current_user.username }} ({{ current_user.role }})</span>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">Register</a>
</li>
{% endif %}
</ul>
</div>
</div>
</nav>
<div class="container mt-4">
<!-- Flash messages block -->
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ category }} alert-dismissible fade show">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- Child templates inject content here -->
{% block content %}{% endblock %}
</div>
<!-- Bootstrap JS bundle for navbar toggle and alert dismiss -->
<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 are placeholders that child templates override with their own content.
- The {% if current_user.is_authenticated %} block shows Login/Register links for guests and a Welcome message plus Logout for logged-in users.
- The get_flashed_messages() loop renders success and error alerts from your route logic.
- Save base.html.
You will not see a visual change yet. The base template needs a child template and the custom stylesheet to render properly.
- Create a new file called style.css inside the trekking_app/static/ folder.
- Paste the following custom styles:
body {
background-color: #f8f9fa;
}
.card {
border-radius: 0.5rem;
}
.navbar-brand {
font-weight: bold;
}
What do these styles do?
These three rules give the app a light grey background, slightly rounded cards, and a bold brand name in the navbar. Bootstrap handles the rest.
- Save style.css.
Create the login and registration forms
Each form page extends base.html and only fills in the content block. This means they automatically inherit the navbar, flash messages, and Bootstrap styling.
- Create a new file called login.html inside trekking_app/templates/.
- Paste the following code:
{% extends "base.html" %}
{% block title %}Login - Trekking App{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-body p-4">
<h2 class="card-title text-center mb-4">Login</h2>
<!-- Login form posts to the auth.login route -->
<form method="POST" action="{{ url_for('auth.login') }}">
<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-primary w-100">Login</button>
</form>
<p class="text-center mt-3">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
</p>
</div>
</div>
</div>
</div>
{% endblock %}
What does this template do?
- The {% extends "base.html" %} line inherits the full layout including navbar and Bootstrap.
- The form uses method="POST" to submit credentials to your login route, which validates them and starts a session.
- The card shadow class creates a centered floating card effect for the form.
- Save login.html.
- In your browser, visit http://127.0.0.1:5000/login. You should see a centered card with Username and Password fields, a blue Login button, and a link to Register.
Seeing a TemplateNotFound error?
- Make sure login.html is saved inside trekking_app/templates/ (not the project root or another folder).
- Check that the filename is exactly login.html with no typos or extra extensions.
Help me debug a TemplateNotFound error in my Flask app.
- Create a new file called register.html inside trekking_app/templates/.
- Paste the following code:
{% extends "base.html" %}
{% block title %}Register - Trekking App{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-body p-4">
<h2 class="card-title text-center mb-4">Register</h2>
<!-- Registration form with role selection -->
<form method="POST" action="{{ url_for('auth.register') }}">
<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">
<option value="user">Trekker (User)</option>
<option value="staff">Trek Staff</option>
</select>
</div>
<button type="submit" class="btn btn-success w-100">Register</button>
</form>
<p class="text-center mt-3">
Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a>
</p>
</div>
</div>
</div>
</div>
{% endblock %}
What does this template do?
- The <select> dropdown lets the user choose between Trekker (User) and Trek Staff roles at registration.
- The form posts to your auth.register route, which hashes the password and sets is_approved=False for staff signups.
- Save register.html.
- Visit http://127.0.0.1:5000/register. You should see a form with Username, Email, Password fields and a "Register as" dropdown.
Create the dashboard and pending templates
Each role sees a different dashboard after logging in. For now, the dashboards display placeholder stat cards that future projects will populate with real data.
- Create a new file called admin_dashboard.html inside trekking_app/templates/.
- Paste the following code:
{% extends "base.html" %}
{% block title %}Admin Dashboard - Trekking App{% endblock %}
{% block content %}
<h1 class="mb-4">Admin Dashboard</h1>
<div class="row">
<!-- Stat cards showing placeholder counts -->
<div class="col-md-4 mb-3">
<div class="card text-white bg-primary">
<div class="card-body">
<h5 class="card-title">Total Treks</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-success">
<div class="card-body">
<h5 class="card-title">Total Users & Staff</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-info">
<div class="card-body">
<h5 class="card-title">Total Bookings</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Admin Actions</h5>
<p class="card-text">Manage treks, approve staff, assign staff to treks, and view all bookings. These features will be built in Project 2.</p>
</div>
</div>
{% endblock %}
What does this template do?
The admin sees three coloured stat cards (Total Treks, Total Users & Staff, Total Bookings) and an actions card describing what will come in future projects. The counts are hardcoded to 0 for now.
- Save admin_dashboard.html.
- Create a new file called staff_dashboard.html inside trekking_app/templates/.
- Paste the following code:
{% extends "base.html" %}
{% block title %}Staff Dashboard - Trekking App{% endblock %}
{% block content %}
<h1 class="mb-4">Staff Dashboard</h1>
<div class="row">
<div class="col-md-6 mb-3">
<div class="card text-white bg-primary">
<div class="card-body">
<h5 class="card-title">Assigned Treks</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card text-white bg-success">
<div class="card-body">
<h5 class="card-title">Registered Participants</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Staff Actions</h5>
<p class="card-text">View assigned treks, update trek slots and status, manage participants. These features will be built in Project 3.</p>
</div>
</div>
{% endblock %}
- Save staff_dashboard.html.
- Create a new file called staff_pending.html inside trekking_app/templates/.
- Paste the following code:
{% extends "base.html" %}
{% block title %}Approval Pending - Trekking App{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow">
<div class="card-body text-center p-5">
<h2 class="text-warning mb-3">Approval Pending</h2>
<p class="lead">Your staff registration is awaiting admin approval.</p>
<p class="text-muted">Once the admin approves your account, you will be able to access your staff dashboard and manage assigned treks.</p>
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-secondary mt-3">Logout</a>
</div>
</div>
</div>
</div>
{% endblock %}
Why a separate pending page?
Staff members who register cannot access their dashboard until an admin approves their account. This page gives them clear feedback instead of a confusing error.
- Save staff_pending.html.
- Create a new file called user_dashboard.html inside trekking_app/templates/.
- Paste the following code:
{% extends "base.html" %}
{% block title %}User Dashboard - Trekking App{% endblock %}
{% block content %}
<h1 class="mb-4">Welcome, {{ current_user.username }}!</h1>
<div class="row">
<div class="col-md-4 mb-3">
<div class="card text-white bg-primary">
<div class="card-body">
<h5 class="card-title">Available Treks</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-success">
<div class="card-body">
<h5 class="card-title">My Bookings</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-secondary">
<div class="card-body">
<h5 class="card-title">Trek History</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">User Actions</h5>
<p class="card-text">Browse treks, book treks, view booking status, and check trekking history. These features will be built in Project 4.</p>
</div>
</div>
{% endblock %}
- Save user_dashboard.html.
All templates are now in place. Time to verify the full flow.
- In your browser, visit http://127.0.0.1:5000/login.
- Enter admin as the username and admin123 as the password.
- Click Login. You should be redirected to the Admin Dashboard showing three coloured stat cards and the navbar displaying "Welcome, admin (admin)" with a Logout link.
Seeing a blank page or an error?
- If you see a Jinja2 error about a missing block or syntax, check that each child template starts with {% extends "base.html" %} on the very first line.
- If the page has no styling, confirm that style.css is inside the trekking_app/static/ folder (not templates/).
- If the login fails with "Invalid username or password", make sure you ran flask --app run init-db in the previous step to seed the admin account.
Help me debug why my Flask templates are not rendering correctly.
✔️ Awesome, I've got everything!
Great. Double check you have saved all 8 files: base.html, login.html, register.html, admin_dashboard.html, staff_dashboard.html, staff_pending.html, user_dashboard.html, and style.css.
ⓧ 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">
<title>{% block title %}Trekking App{% 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">
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container">
<a class="navbar-brand" href="{{ url_for('main.index') }}">Trekking App</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav ms-auto">
{% if current_user.is_authenticated %}
<li class="nav-item">
<span class="nav-link text-light">Welcome, {{ current_user.username }} ({{ current_user.role }})</span>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.logout') }}">Logout</a>
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.login') }}">Login</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.register') }}">Register</a>
</li>
{% endif %}
</ul>
</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">
{{ 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>
{% extends "base.html" %}
{% block title %}Login - Trekking App{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-body p-4">
<h2 class="card-title text-center mb-4">Login</h2>
<form method="POST" action="{{ url_for('auth.login') }}">
<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-primary w-100">Login</button>
</form>
<p class="text-center mt-3">
Don't have an account? <a href="{{ url_for('auth.register') }}">Register here</a>
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Register - Trekking App{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card shadow">
<div class="card-body p-4">
<h2 class="card-title text-center mb-4">Register</h2>
<form method="POST" action="{{ url_for('auth.register') }}">
<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">
<option value="user">Trekker (User)</option>
<option value="staff">Trek Staff</option>
</select>
</div>
<button type="submit" class="btn btn-success w-100">Register</button>
</form>
<p class="text-center mt-3">
Already have an account? <a href="{{ url_for('auth.login') }}">Login here</a>
</p>
</div>
</div>
</div>
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Admin Dashboard - Trekking App{% endblock %}
{% block content %}
<h1 class="mb-4">Admin Dashboard</h1>
<div class="row">
<div class="col-md-4 mb-3">
<div class="card text-white bg-primary">
<div class="card-body">
<h5 class="card-title">Total Treks</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-success">
<div class="card-body">
<h5 class="card-title">Total Users & Staff</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-info">
<div class="card-body">
<h5 class="card-title">Total Bookings</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Admin Actions</h5>
<p class="card-text">Manage treks, approve staff, assign staff to treks, and view all bookings. These features will be built in Project 2.</p>
</div>
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Staff Dashboard - Trekking App{% endblock %}
{% block content %}
<h1 class="mb-4">Staff Dashboard</h1>
<div class="row">
<div class="col-md-6 mb-3">
<div class="card text-white bg-primary">
<div class="card-body">
<h5 class="card-title">Assigned Treks</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-6 mb-3">
<div class="card text-white bg-success">
<div class="card-body">
<h5 class="card-title">Registered Participants</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">Staff Actions</h5>
<p class="card-text">View assigned treks, update trek slots and status, manage participants. These features will be built in Project 3.</p>
</div>
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}Approval Pending - Trekking App{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card shadow">
<div class="card-body text-center p-5">
<h2 class="text-warning mb-3">Approval Pending</h2>
<p class="lead">Your staff registration is awaiting admin approval.</p>
<p class="text-muted">Once the admin approves your account, you will be able to access your staff dashboard and manage assigned treks.</p>
<a href="{{ url_for('auth.logout') }}" class="btn btn-outline-secondary mt-3">Logout</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% extends "base.html" %}
{% block title %}User Dashboard - Trekking App{% endblock %}
{% block content %}
<h1 class="mb-4">Welcome, {{ current_user.username }}!</h1>
<div class="row">
<div class="col-md-4 mb-3">
<div class="card text-white bg-primary">
<div class="card-body">
<h5 class="card-title">Available Treks</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-success">
<div class="card-body">
<h5 class="card-title">My Bookings</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
<div class="col-md-4 mb-3">
<div class="card text-white bg-secondary">
<div class="card-body">
<h5 class="card-title">Trek History</h5>
<p class="card-text display-6">0</p>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-body">
<h5 class="card-title">User Actions</h5>
<p class="card-text">Browse treks, book treks, view booking status, and check trekking history. These features will be built in Project 4.</p>
</div>
</div>
{% endblock %}
body {
background-color: #f8f9fa;
}
.card {
border-radius: 0.5rem;
}
.navbar-brand {
font-weight: bold;
}
Your templates are complete. Every role now has a styled home to land on after authentication. Next up, you will wire the role-based routing so that each login experience is protected and each user lands on the correct dashboard.
Implement Role-Based Dashboard Routing
You've built the authentication routes, designed the database, and created all the templates. The final piece of the puzzle is confirming that everything connects correctly.
In this step, you'll walk through the routing logic that directs each role to their correct dashboard and then test the entire system end-to-end in the browser.
In this step, get ready to:
- Review how the main blueprint routes direct each role to their dashboard.
- Verify the staff pending approval workflow.
- Test access control by logging in as each role and attempting unauthorized access.
Review the routing logic in main.py
The main.py blueprint handles all dashboard routing. Each role has its own protected route.
- Switch to main.py in VS Code.
- Look at the index() route at the top of your routes section.
@main_bp.route('/')
def index():
if current_user.is_authenticated:
if current_user.role == 'admin':
return redirect(url_for('main.admin_dashboard'))
elif current_user.role == 'staff':
if not current_user.is_approved:
return redirect(url_for('main.staff_pending'))
return redirect(url_for('main.staff_dashboard'))
else:
return redirect(url_for('main.user_dashboard'))
return redirect(url_for('auth.login'))
What does this route do?
The root route / acts as a traffic controller. It checks if the user is logged in and what role they have, then sends them to the correct dashboard.
If no one is logged in, it redirects to the login page. This means visiting http://127.0.0.1:5000/ always lands the user somewhere appropriate.
- Now look at the three dashboard routes below it.
@main_bp.route('/admin/dashboard')
@login_required
@role_required('admin')
def admin_dashboard():
return render_template('admin_dashboard.html')
@main_bp.route('/staff/dashboard')
@login_required
@role_required('staff')
def staff_dashboard():
if not current_user.is_approved:
return redirect(url_for('main.staff_pending'))
return render_template('staff_dashboard.html')
@main_bp.route('/user/dashboard')
@login_required
@role_required('user')
def user_dashboard():
return render_template('user_dashboard.html')
How do the decorators protect each route?
Each dashboard route is wrapped with two decorators. @login_required ensures only authenticated users can access the route. @role_required('admin') checks that the logged-in user has the correct role.
If a regular user tries to visit /admin/dashboard, the role_required decorator calls abort(403) which returns a Forbidden error.
Understand the staff approval check
Staff members have an extra gate. Even after passing authentication and role checks, they must also be approved by an admin before seeing their dashboard.
- Find the staff_dashboard() route and notice the if not current_user.is_approved: check inside it.
- Find the staff_pending() route directly below it.
@main_bp.route('/staff/pending')
@login_required
@role_required('staff')
def staff_pending():
return render_template('staff_pending.html')
Why a separate pending route?
When staff registers, their is_approved field is set to False. The login route in auth.py checks this immediately after login and redirects unapproved staff to /staff/pending instead of their dashboard.
The staff_dashboard route also has its own approval check as a safety net. Even if someone bypasses the login redirect by typing the URL directly, they still cannot see the dashboard.
Test the complete flow end-to-end
Time to prove it all works. You'll log in as each role and verify that the routing sends each user to the correct page.
- Make sure your Flask dev server is still running. If it stopped, restart it by running:
flask --app run run --debug
- Open your browser and navigate to http://127.0.0.1:5000/.
- You should be redirected to the login page.
Test 1: Admin login.
- Enter admin in the Username field.
- Enter admin123 in the Password field.
- Click Login.
- You should be redirected to the Admin Dashboard showing three cards (Total Treks, Total Users & Staff, Total Bookings) all displaying 0.
- The navbar should show "Welcome, admin (admin)" and a Logout link.
- Click Logout.
Not seeing the Admin Dashboard?
Make sure you ran flask --app run init-db in a previous step. Without the seeded admin account, there is no admin user in the database.
Check that you typed admin (lowercase) as the username and admin123 as the password exactly.
Help me debug my admin login issue.
Test 2: User registration and login.
- Click Register in the navbar.
- Enter testuser in the Username field.
- Enter testuser@example.com in the Email field.
- Enter password123 in the Password field.
- Leave the Register as dropdown set to Trekker (User).
- Click Register.
- You should see a green flash message saying "Registration successful! Please log in." and be redirected to the login page.
- Log in with testuser and password123.
- You should be redirected to the User Dashboard showing "Welcome, testuser!" with cards for Available Treks, My Bookings, and Trek History.
Test 3: Access control.
- While still logged in as testuser, type http://127.0.0.1:5000/admin/dashboard directly in the browser address bar and press Enter.
- You should see a "403 Forbidden" error page. This proves the @role_required('admin') decorator is blocking unauthorized access.
- Click Logout in the navbar (or navigate back to http://127.0.0.1:5000/).
Test 4: Staff pending approval.
- Click Register in the navbar.
- Enter teststaff in the Username field.
- Enter teststaff@example.com in the Email field.
- Enter password123 in the Password field.
- Select Trek Staff from the Register as dropdown.
- Click Register.
- You should see a blue flash message saying "Registration successful! Awaiting admin approval."
- Log in with teststaff and password123.
- You should see the "Approval Pending" page with a warning that your account is awaiting admin approval.
What should I see?
The pending page shows a yellow "Approval Pending" heading, a message explaining the wait, and a Logout button. The staff member cannot access any dashboard until an admin sets their is_approved field to True in the database.
Staff seeing the dashboard instead of pending page?
Check that the registration route in auth.py sets is_approved=(role == 'user'). This means staff accounts get is_approved=False while user accounts get is_approved=True.
If you registered staff before this logic was in place, delete the instance/trekking.db file, re-run flask --app run init-db, and register again.
Help me debug why my staff account isn't showing the pending page.
Your role-based routing is fully operational. Every role lands on their correct dashboard, unauthorized access is blocked, and unapproved staff see a clear holding page. Your trekking app now has a complete, professional authentication system ready to build features on top of in future projects.
Secret mission
Add a Profile Editing Page
Your users can register and log in, but what happens when they want to update their details? In this bonus challenge, you'll build a profile editing page that lets any authenticated user change their username and email. This introduces form pre-population, UPDATE queries, and uniqueness validation on existing records.
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 with SQLite, so there are no ongoing costs.
Resources you used:
- .venv/ folder (Python virtual environment with Flask and dependencies).
- instance/trekking.db SQLite database file (users, treks, bookings, staff assignments tables).
Keep everything running
No action needed. Choose this if you plan to continue building on this project in the next part of the series.
- Your Flask app runs locally with zero ongoing cost.
- The virtual environment and SQLite database stay on disk but consume minimal space.
- To start the app again later, activate the virtual environment and run the Flask dev server.
Pause - I'll come back to this later
Shut down the Flask dev server and deactivate the virtual environment. Your files stay on disk and you can pick up where you left off.
- Stop the Flask development server by pressing Ctrl+C in the terminal where it is running.
- Deactivate the virtual environment by running:
deactivate
When you come back, reactivate the virtual environment from inside your project folder.
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
Delete - I don't want to use this again
Remove all project files from your machine. This deletes your code, virtual environment, and database permanently.
- Stop the Flask development server by pressing Ctrl+C in the terminal.
- Deactivate the virtual environment by running:
deactivate
- Navigate to your Desktop and delete the entire project folder.
macOS
- Remove the project folder by running:
cd ~/Desktop
rm -rf trekking-app
Windows
- Remove the project folder by running:
cd ~\Desktop
rmdir /s /q trekking-app
- Confirm the folder is gone by running ls (macOS) or dir (Windows) on your Desktop. You should no longer see the project folder listed.
Nice Work!
Nice Work!
Nice work! You just built a full-stack Flask application with multi-role authentication from scratch.
You've learned how to:
- Build a Flask application using the application factory pattern with blueprints to organize routes into modular, maintainable components.
- Design and programmatically create an SQLite database with five related SQLAlchemy ORM models covering Users, Treks, Bookings, and Staff Assignments.
- Implement a complete session-based authentication system with registration, login, logout, password hashing, and role-based access control using custom decorators.
- Secret Mission: Added a profile editing page with form pre-population and database updates.
Ready to quiz yourself?