Project: a trail log web app
A multi-page app with a database, accounts, forms and tests.
Create a free account Try part of it here
Why it matters
Each lesson so far built one piece: pages, forms, accounts, a database. Real apps are those pieces wired together, and most of the bugs live in the wiring: a list that shows everyone's records, a delete button that works on someone else's data, a form that saves half its fields. This project builds a complete app for the Trail Club, from data model to tests, and the result is a good base for a portfolio piece of your own.
What you'll be able to do
- Plan a small web app as pages, routes and data before writing views
- Combine Flask, SQLAlchemy, bcrypt and Jinja into one working app
- Make sure people can see and change only their own records
- Test a whole app through its pages with the test client
What it covers
- The brief
- Step 1: the data
- Totals in the database, not in Python
- Step 2: only your own records
- Step 3: the add form
- Worked example: an app factory for tests
- Running it on your own computer
- A gear-repair directory
The brief
Trail Club members want a simple hiking log. A member can:
- sign up and log in;
- add a hike: which trail, which day and how many kilometers;
- see their own hikes, newest first, with a running total;
- delete one of their own hikes.
Nobody may see or change anyone else's hikes. The app keeps its data in SQLite through SQLAlchemy, as in the ORM lesson, and every page is tested through the test client.
Before any code, list the routes: each URL, its method, and what it does. This table is the app's plan, and it's also the outline of the tests you'll write.
| Route | Who | Does |
|---|---|---|
POST /signup, POST /login, POST /logout | anyone | accounts, as in the login lesson |
GET /hikes | members | your hikes, newest first, with totals and the add form |
POST /hikes | members | check the form; save, flash and redirect, or show errors with 400 |
POST /hikes/<id>/delete | the hike's owner | delete it and redirect; anyone else gets 404 |
Flask's @app.get("/hikes") and @app.post("/hikes") are short for @app.route(..., methods=[...]), and they let one URL have a separate view for each method.
from flask import Flask app = Flask("demo") @app.get("/hikes") def list_hikes(): return "list" @app.post("/hikes") def add_hike(): return "added" c = app.test_client() print(c.get("/hikes").get_data(as_text=True), c.post("/hikes").get_data(as_text=True), c.delete("/hikes").status_code)
Show the answer
list added 405
Each method goes to its own view. DELETE has no view at that URL, so Flask answers 405 Method Not Allowed.
Step 1: the data
Two tables: users and hikes, with each hike belonging to one user. The models come straight from the ORM lesson. Each request gets its own database session, opened when a view first needs it and closed when the request ends, which Flask's g object and a teardown function make easy:
from datetime import date from flask import Flask, g from sqlalchemy import create_engine, ForeignKey, select from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session class Base(DeclarativeBase): pass class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(unique=True) name: Mapped[str] hikes: Mapped[list["Hike"]] = relationship(back_populates="user") class Hike(Base): __tablename__ = "hikes" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) trail: Mapped[str] day: Mapped[date] km: Mapped[float] user: Mapped[User] = relationship(back_populates="hikes") engine = create_engine("sqlite://") Base.metadata.create_all(engine) app = Flask(__name__) def db(): if "db" not in g: g.db = Session(engine) return g.db @app.teardown_appcontext def close_db(error): session = g.pop("db", None) if session is not None: session.close() with app.app_context(): ada = User(email="ada@example.com", name="Ada") db().add_all([ada, Hike(user=ada, trail="Red Mesa", day=date(2025, 6, 14), km=11.5)]) db().commit() with app.app_context(): for hike in db().scalars(select(Hike)): print(hike.user.name, hike.trail, hike.day, hike.km)
sqlite:// with nothing after it is a database in memory, perfect for trying things out and for tests. A real deployment would point at a file or a database server, read from an environment variable.
Totals in the database, not in Python
Set result to a tuple (number of hikes, total km) for Ada only, using one query with func.count and func.sum. Lin's hikes must not count.
from datetime import date from sqlalchemy import create_engine, ForeignKey, select, func from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, Session class Base(DeclarativeBase): pass class User(Base): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) name: Mapped[str] class Hike(Base): __tablename__ = "hikes" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int] = mapped_column(ForeignKey("users.id")) trail: Mapped[str] km: Mapped[float] engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as s: ada, lin = User(name="Ada"), User(name="Lin") s.add_all([ada, lin]) s.flush() s.add_all([Hike(user_id=ada.id, trail="Red Mesa", km=11.5), Hike(user_id=ada.id, trail="Fern Hollow", km=6.0), Hike(user_id=lin.id, trail="Red Mesa", km=11.5), Hike(user_id=ada.id, trail="Creek loop", km=6.0)]) s.commit() ada_id = ada.id with Session(engine) as s: result = None # (count, total km) for ada_id, from one query print(result)
Step 2: only your own records
The most important rule in this app is one line in every query: filter by the signed-in user. The list page asks for select(Hike).where(Hike.user_id == g.user.id), never for all hikes. Deleting looks the hike up by both its id and its owner:
hike = db().scalars(select(Hike).where(Hike.id == hike_id, Hike.user_id == g.user.id)).first() if hike is None: abort(404)
Why 404 and not 403? A 403 would confirm that hike 17 exists and belongs to someone else. To a member, other people's hikes simply don't exist, so the honest answer is "not found". This bug class, reaching another person's record by changing a number in the URL, is called an insecure direct object reference, and it's one of the most common flaws found in real web apps. The fix is always the same: every lookup is scoped to the owner.
Accounts come from the login lesson unchanged: bcrypt hashes, session.clear() at login, a before_request function that puts the signed-in user in g.user, and a login_required decorator.
The same checks run in the same order for every request a member makes.
Step 3: the add form
The add form follows the forms lesson exactly. A check_hike(form) function returns cleaned values and errors:
- trail: required, with spaces trimmed;
- day: an ISO date (
2025-06-14), not in the future; - km: a number above 0 and at most 100 (a typo like 115 instead of 11.5 should be caught, not saved).
On errors, the view shows the hikes page again with the messages, the typed values and status 400. On success it saves, flashes "Hike saved." and redirects with 303 to /hikes. The project fixes "today" as a constant, TODAY, so the tests give the same result on any day; a real app would use date.today() and its tests would replace it.
Step 4: test it like a user
Each test signs up its own users with the test client, then does what a person would do: add hikes, look at the list, try something they shouldn't. The checks for this project follow the routes table: one or more checks per row, plus the rule that matters most, that one member never sees or deletes another member's hikes. When you build your own app, write those tests first. They're the fastest way to know the wiring holds as the app grows.
Worked example: an app factory for tests
Real projects usually build the app in a function, create_app(database_url), so each test gets a fresh app with its own empty database. Here is the pattern at its smallest.
from flask import Flask, g from sqlalchemy import create_engine, text def create_app(database_url="sqlite://"): app = Flask(__name__) engine = create_engine(database_url) with engine.begin() as conn: conn.execute(text("CREATE TABLE IF NOT EXISTS visits (n INTEGER)")) @app.get("/") def home(): with engine.begin() as conn: conn.execute(text("INSERT INTO visits VALUES (1)")) count = conn.execute(text("SELECT COUNT(*) FROM visits")).scalar_one() return f"visit number {count}" return app first = create_app().test_client() second = create_app().test_client() print(first.get("/").get_data(as_text=True)) print(first.get("/").get_data(as_text=True)) print(second.get("/").get_data(as_text=True))
Each call to create_app() makes a separate app with its own in-memory database, so the second client starts counting from one again. In pytest, a fixture would call create_app() and return app.test_client(), and no test could ever see data left behind by another. Larger Flask apps also split their views into blueprints, groups of routes in separate modules that the factory registers. The exercise keeps everything in one module so you can read it top to bottom, and resets its database between tests instead.
Running it on your own computer
To run the app outside the academy, save it as trail_log.py in a project folder with a virtual environment, as the local setup lesson showed. Then pip install flask sqlalchemy bcrypt, set SECRET_KEY and DATABASE_URL as environment variables, and start it with flask --app trail_log run --debug. Change sqlite:// to sqlite:///trail_log.db so the data survives restarts.
Two things a real deployment adds: migrations (with Alembic) so you can add a column later without losing data, and a production server such as Gunicorn behind HTTPS. The containers lesson packages an app like this one into an image you can deploy anywhere.
A gear-repair directory
Hikers keep throwing away tents and jackets that a repair shop could fix in an afternoon. Build a web app that helps them find one: a directory of repair shops, searchable by town and by the kind of gear they fix.
Anyone can browse and search. Signed-in members can suggest a new shop, which waits for an administrator to approve it before it appears, and can leave one review per shop. It's a small version of a real product, so the parts that matter are the ones a real product gets wrong: validating what people submit, checking who's allowed to do what, and keeping one person's actions from touching another's data.
It must
- Flask pages to list shops, filter them by town and gear type, and show one shop with its reviews
- Data stored with SQLAlchemy: shops, members and reviews, with the right relationships
- Members can sign up, log in and log out, with passwords hashed with bcrypt
- A form to suggest a shop, validated on the server, that creates it as pending until an administrator approves it
- Tests with Flask's test client for browsing, a rejected form, a pending shop staying hidden, and one review per member per shop
Go further
- Show each shop's average rating and sort by it
- A JSON API for the directory alongside the pages
- Pagination for long lists
from flask import Flask, render_template_string, request app = Flask(__name__) SHOPS = [ {"name": "Stitch & Seam", "town": "Fern Hollow", "fixes": ["tents", "jackets"]}, {"name": "Ridge Cobbler", "town": "North Ridge", "fixes": ["boots"]}, ] PAGE = """<h1>Gear repair</h1> <ul>{% for s in shops %}<li>{{ s.name }}, {{ s.town }}: {{ s.fixes | join(", ") }}</li>{% endfor %}</ul>""" @app.route("/") def home(): town = request.args.get("town") shops = [s for s in SHOPS if not town or s["town"] == town] return render_template_string(PAGE, shops=shops) client = app.test_client() print(client.get("/?town=Fern Hollow").get_data(as_text=True)) # your code: a database, accounts, suggestions, reviews and tests
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
A member changes /hikes/17/delete to /hikes/18/delete and deletes someone else's hike. What's the fix?