🛠️ Weekend Project: Flask
Everything you learned across Module 17 comes together this weekend. You'll build Bookshelf — a real, multi-user library manager with authentication, a database, server-rendered pages, and a JSON REST API — one milestone at a time. This is a guided build, not a copy-paste: each milestone tells you what to ship and how to know it works.
🎯 Learning Objectives
By the end of this project, you will be able to:
- Scaffold a production-shaped Flask 3 app using the application-factory and blueprint patterns
- Model related data with SQLAlchemy 2.0 typed models (
Mapped/mapped_column) and manage schema changes with Flask-Migrate - Implement secure registration and login with Flask-Login and hashed passwords
- Build full CRUD plus a serialized REST API with Marshmallow, enforcing ownership checks
- Self-assess your build against a concrete "what good looks like" bar
Estimated Time: A weekend (10–14 focused hours) • Difficulty: Intermediate (capstone)
Hands-on: This whole lesson is the exercise — you ship a running application, milestone by milestone.
In This Lesson
How to Approach a Weekend Build
A full application feels overwhelming if you try to hold all of it in your head at once. The cure is an old one. Mathematician George Pólya described a four-step way to attack any hard problem, and it maps perfectly onto shipping software:
- Understand the problem — get crisp on what you're building and for whom.
- Devise a plan — break the whole into small, orderable milestones.
- Carry out the plan — build one milestone at a time, verifying as you go.
- Look back — test, refactor, and compare against a quality bar.
the problem"] --> B["Devise
a plan"] B --> C["Carry out
the plan"] C --> D["Look
back"] D -->|"Refine & iterate"| B
💡 The golden rule of a weekend build: always keep the app runnable. Never leave your codebase in a state where flask run crashes overnight. Finish each milestone at a point where you can start the server, click around, and see something work. A broken-but-half-finished feature is worse than a smaller feature that ships.
Treat the milestones below as save points in a game. Reach one, confirm it works, commit your code, then move on. If you run out of weekend, you'll still have a working — if smaller — application.
The Blueprint: What You're Building
Bookshelf is a personal library manager. A signed-in user can catalog their books, organize them into reading lists (like "Currently Reading" or "Want to Read"), rate and review titles, and search their collection. Everything is also reachable through a JSON API, so a future mobile app or script could use the same data.
Understand the problem (Pólya step 1)
Before writing code, answer four questions in one sentence each:
| Question | Answer for Bookshelf |
|---|---|
| What data do we store? | Users, Books, Reading Lists, and Reviews — and the relationships between them. |
| What does the web UI do? | Lets a user browse, add, edit, delete, search, list, and review their books. |
| What does the API provide? | CRUD over books, reading lists, and reviews as JSON, protected by auth. |
| What are the constraints? | Flask 3, SQLAlchemy 2.0, Flask-Login, Flask-Migrate, Marshmallow, SQLite for dev. |
The data model at a glance
Four entities, connected by three kinds of relationship. A User owns many books, lists, and reviews; a Book can appear in many reading lists (and a list holds many books — a many-to-many); each Review ties one user to one book.
📖 Key terms
Application factory: a create_app() function that builds and configures the Flask app, so you can create fresh instances (e.g. for tests) instead of relying on one global.
Blueprint: a self-contained group of routes (auth, books, api…) you register onto the app — the way Flask keeps a growing project organized.
Association (join) table: the extra table that stores a many-to-many link, here connecting reading lists and books.
The Milestone Roadmap
Here is the plan (Pólya step 2). Each milestone ends at a runnable, verifiable state. Aim to reach Milestone 3 on Saturday and Milestone 4 on Sunday; the stretch goals are dessert.
factory + blueprints + config"] --> M2["M2 · Models
typed SQLAlchemy + migration"] M2 --> M3["M3 · Auth
register / login / logout"] M3 --> M4["M4 · CRUD + API
books, reviews, JSON endpoints"] M4 --> S["Stretch
reading lists, search, tests, deploy"]
| When | Milestones | You should be able to… |
|---|---|---|
| Saturday AM | M1 + M2 | Start the server; create tables; open a shell and add a Book. |
| Saturday PM | M3 | Register an account, log in, and log out in the browser. |
| Sunday | M4 | Add/edit/delete/review books in the UI and via curl. |
| Bonus | Stretch | Reading lists, search, a few tests, and a deploy. |
⚠️ Commit at every milestone
Run git init before Milestone 1 and make a commit the moment each milestone works. If a later change breaks everything, you can always git stash or reset to your last green state instead of debugging in a panic.
Milestone 1 — Scaffold the App
Goal: a Flask 3 app that starts, using the application-factory and blueprint patterns, with configuration loaded from the environment.
Create the environment
# Create and enter the project
mkdir bookshelf && cd bookshelf
# Virtual environment
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
# Install the stack (Flask 3 + SQLAlchemy 2.0 era packages)
pip install "flask>=3.0" flask-sqlalchemy flask-migrate flask-login \
flask-wtf flask-marshmallow marshmallow-sqlalchemy \
python-dotenv email-validator
pip freeze > requirements.txt
The target structure
Don't create every file yet — just know where things go. You'll add folders as each milestone needs them.
bookshelf/
├── app/
│ ├── __init__.py # create_app() factory
│ ├── config.py # Config class (reads .env)
│ ├── extensions.py # db, migrate, login, ma singletons
│ ├── models/ # M2: User, Book, ReadingList, Review
│ ├── auth/ # M3: blueprint (routes + forms)
│ ├── books/ # M4: blueprint (routes + forms)
│ ├── api/ # M4: blueprint (resources + schemas)
│ ├── main/ # home & about
│ ├── templates/
│ └── static/
├── migrations/ # created by `flask db init`
├── .env # SECRET_KEY, DATABASE_URL (never commit)
├── .gitignore
└── run.py # entry point
Configuration & extensions
Keep secrets out of code. The config reads from a .env file; the extensions are created once here and wired to the app inside the factory.
# app/config.py
import os
from dotenv import load_dotenv
load_dotenv()
class Config:
SECRET_KEY = os.environ["SECRET_KEY"] # required — fail loudly if missing
SQLALCHEMY_DATABASE_URI = os.environ.get("DATABASE_URL", "sqlite:///bookshelf.db")
SQLALCHEMY_TRACK_MODIFICATIONS = False
# app/extensions.py
from flask_sqlalchemy import SQLAlchemy
from flask_sqlalchemy.model import Model
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_marshmallow import Marshmallow
from sqlalchemy.orm import DeclarativeBase
# SQLAlchemy 2.0 style: a typed declarative base
class Base(DeclarativeBase):
pass
db = SQLAlchemy(model_class=Base)
migrate = Migrate()
login = LoginManager()
login.login_view = "auth.login"
login.login_message = "Please log in to access this page."
ma = Marshmallow()
# app/__init__.py
from flask import Flask
from app.config import Config
from app.extensions import db, migrate, login, ma
def create_app(config_class=Config):
app = Flask(__name__)
app.config.from_object(config_class)
# Wire extensions to this app instance
db.init_app(app)
migrate.init_app(app, db)
login.init_app(app)
ma.init_app(app)
# Import models so migrations can see them
from app import models # noqa: F401
# Register blueprints (added in later milestones)
from app.main import main_bp
app.register_blueprint(main_bp)
# from app.auth import auth_bp; app.register_blueprint(auth_bp)
# from app.books import books_bp; app.register_blueprint(books_bp)
# from app.api import api_bp; app.register_blueprint(api_bp, url_prefix="/api")
return app
A minimal main blueprint gives you something to load right away:
# app/main/__init__.py
from flask import Blueprint
main_bp = Blueprint("main", __name__)
from app.main import routes # noqa: E402,F401
# app/main/routes.py
from app.main import main_bp
@main_bp.route("/")
def index():
return "<h1>Bookshelf is running 🎉</h1>"
# run.py
from app import create_app
app = create_app()
✅ Milestone 1 done when…
You run flask --app run run --debug (with SECRET_KEY set in .env), open http://localhost:5000, and see "Bookshelf is running". Commit.
Milestone 2 — Typed Data Models
Goal: define all four models in the modern SQLAlchemy 2.0 typed style, then create the schema with a migration.
SQLAlchemy 2.0 uses Python type hints via Mapped[...] and mapped_column(...). This gives you editor autocompletion and static type-checking that the old db.Column style never had. Notice how Mapped[str] means "not null" while Mapped[str | None] means "nullable".
# app/models/user.py
from datetime import datetime, timezone
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash
from sqlalchemy import String, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db, login
def utcnow() -> datetime:
return datetime.now(timezone.utc)
class User(UserMixin, db.Model):
__tablename__ = "user"
id: Mapped[int] = mapped_column(primary_key=True)
username: Mapped[str] = mapped_column(String(64), unique=True, index=True)
email: Mapped[str] = mapped_column(String(120), unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String(256))
joined_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# Relationships (typed collections)
books: Mapped[list["Book"]] = relationship(
back_populates="owner", cascade="all, delete-orphan")
reading_lists: Mapped[list["ReadingList"]] = relationship(
back_populates="owner", cascade="all, delete-orphan")
reviews: Mapped[list["Review"]] = relationship(
back_populates="author", cascade="all, delete-orphan")
def set_password(self, password: str) -> None:
self.password_hash = generate_password_hash(password)
def check_password(self, password: str) -> bool:
return check_password_hash(self.password_hash, password)
def __repr__(self) -> str:
return f"<User {self.username}>"
@login.user_loader
def load_user(user_id: str):
return db.session.get(User, int(user_id))
# app/models/book.py
from datetime import datetime
from sqlalchemy import String, Integer, Text, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db
from app.models.user import utcnow
class Book(db.Model):
__tablename__ = "book"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200), index=True)
author: Mapped[str] = mapped_column(String(100), index=True)
isbn: Mapped[str | None] = mapped_column(String(20), index=True)
publisher: Mapped[str | None] = mapped_column(String(100))
publication_year: Mapped[int | None] = mapped_column(Integer)
description: Mapped[str | None] = mapped_column(Text)
genre: Mapped[str | None] = mapped_column(String(50), index=True)
cover_image: Mapped[str | None] = mapped_column(String(300))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
owner: Mapped["User"] = relationship(back_populates="books")
reviews: Mapped[list["Review"]] = relationship(
back_populates="book", cascade="all, delete-orphan")
@property
def average_rating(self) -> float:
if not self.reviews:
return 0.0
return sum(r.rating for r in self.reviews) / len(self.reviews)
def __repr__(self) -> str:
return f"<Book {self.title!r} by {self.author}>"
The many-to-many between reading lists and books needs a join table. In 2.0 you can still declare it with db.Table and point relationship(secondary=...) at it:
# app/models/reading_list.py
from datetime import datetime
from sqlalchemy import String, Text, DateTime, ForeignKey, Table, Column
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db
from app.models.user import utcnow
# Association table for ReadingList <-> Book (many-to-many)
reading_list_books = Table(
"reading_list_books",
db.metadata,
Column("reading_list_id", ForeignKey("reading_list.id"), primary_key=True),
Column("book_id", ForeignKey("book.id"), primary_key=True),
)
class ReadingList(db.Model):
__tablename__ = "reading_list"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100))
description: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
owner: Mapped["User"] = relationship(back_populates="reading_lists")
books: Mapped[list["Book"]] = relationship(secondary=reading_list_books)
def __repr__(self) -> str:
return f"<ReadingList {self.name!r}>"
# app/models/review.py
from datetime import datetime
from sqlalchemy import Integer, Text, DateTime, ForeignKey, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.extensions import db
from app.models.user import utcnow
class Review(db.Model):
__tablename__ = "review"
__table_args__ = (
UniqueConstraint("user_id", "book_id", name="uix_user_book_review"),
)
id: Mapped[int] = mapped_column(primary_key=True)
rating: Mapped[int] = mapped_column(Integer) # 1–5 stars
content: Mapped[str | None] = mapped_column(Text)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=utcnow, onupdate=utcnow)
user_id: Mapped[int] = mapped_column(ForeignKey("user.id"))
book_id: Mapped[int] = mapped_column(ForeignKey("book.id"))
author: Mapped["User"] = relationship(back_populates="reviews")
book: Mapped["Book"] = relationship(back_populates="reviews")
Expose all models from the package so the factory's import app.models registers them:
# app/models/__init__.py
from app.models.user import User
from app.models.book import Book
from app.models.reading_list import ReadingList, reading_list_books
from app.models.review import Review
__all__ = ["User", "Book", "ReadingList", "Review", "reading_list_books"]
Create the schema with Flask-Migrate
export FLASK_APP=run.py # Windows: set FLASK_APP=run.py
flask db init # one time — creates migrations/
flask db migrate -m "initial schema"
flask db upgrade # applies it to bookshelf.db
💡 Verify in the shell
The UniqueConstraint on Review means one user can review a book only once — the app enforces "update, don't duplicate". Prove your models work before building any UI:
flask shell
>>> from app.extensions import db
>>> from app.models import User, Book
>>> u = User(username="ray", email="ray@example.com"); u.set_password("secret123")
>>> db.session.add(u); db.session.commit()
>>> b = Book(title="Dune", author="Frank Herbert", owner=u)
>>> db.session.add(b); db.session.commit()
>>> u.books
[<Book 'Dune' by Frank Herbert>]
✅ Milestone 2 done when…
flask db upgrade runs cleanly and the shell session above works end to end. Commit.
Milestone 3 — Authentication
Goal: a visitor can register, log in, and log out. Passwords are hashed; the session is managed by Flask-Login.
Flask-WTF forms give you CSRF protection and validation for free. Custom validate_* methods reject duplicate usernames and emails.
# app/auth/forms.py
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length, ValidationError
from app.extensions import db
from app.models import User
class RegistrationForm(FlaskForm):
username = StringField("Username", validators=[DataRequired(), Length(3, 64)])
email = StringField("Email", validators=[DataRequired(), Email()])
password = PasswordField("Password", validators=[DataRequired(), Length(min=8)])
password2 = PasswordField(
"Confirm Password", validators=[DataRequired(), EqualTo("password")])
submit = SubmitField("Register")
def validate_username(self, field):
if db.session.scalar(db.select(User).filter_by(username=field.data)):
raise ValidationError("That username is taken.")
def validate_email(self, field):
if db.session.scalar(db.select(User).filter_by(email=field.data)):
raise ValidationError("That email is already registered.")
class LoginForm(FlaskForm):
username = StringField("Username", validators=[DataRequired()])
password = PasswordField("Password", validators=[DataRequired()])
remember_me = BooleanField("Remember Me")
submit = SubmitField("Sign In")
# app/auth/routes.py
from urllib.parse import urlsplit
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_user, logout_user, current_user, login_required
from app.extensions import db
from app.auth import auth_bp
from app.auth.forms import LoginForm, RegistrationForm
from app.models import User
@auth_bp.route("/register", methods=["GET", "POST"])
def register():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = RegistrationForm()
if form.validate_on_submit():
user = User(username=form.username.data, email=form.email.data)
user.set_password(form.password.data)
db.session.add(user)
db.session.commit()
flash("Account created — please sign in.", "success")
return redirect(url_for("auth.login"))
return render_template("auth/register.html", title="Register", form=form)
@auth_bp.route("/login", methods=["GET", "POST"])
def login():
if current_user.is_authenticated:
return redirect(url_for("main.index"))
form = LoginForm()
if form.validate_on_submit():
user = db.session.scalar(
db.select(User).filter_by(username=form.username.data))
if user is None or not user.check_password(form.password.data):
flash("Invalid username or password.", "danger")
return redirect(url_for("auth.login"))
login_user(user, remember=form.remember_me.data)
# Only allow relative redirects — blocks open-redirect attacks
next_page = request.args.get("next")
if not next_page or urlsplit(next_page).netloc != "":
next_page = url_for("main.index")
return redirect(next_page)
return render_template("auth/login.html", title="Sign In", form=form)
@auth_bp.route("/logout")
def logout():
logout_user()
flash("You have been logged out.", "info")
return redirect(url_for("main.index"))
⚠️ Two security details that matter
Never store plain passwords. generate_password_hash salts and hashes; you only ever compare hashes. Validate the next parameter on login — an attacker can craft ?next=https://evil.example to bounce a logged-in user off your site. Rejecting any URL with a network location (netloc) closes that hole.
Register the blueprint in the factory (uncomment the line from M1) and create app/auth/__init__.py mirroring the main one, with url_prefix="/auth". Build simple login.html and register.html templates that render form.hidden_tag() and each field.
✅ Milestone 3 done when…
You can register a new account, get redirected to login, sign in, see a "logged in" state, and log out — all in the browser. Try a wrong password and confirm you're rejected. Commit.
Milestone 4 — CRUD & REST API
Goal: signed-in users manage their books through the UI, and the same data is available as JSON through a REST API. Both paths enforce that you can only change what you own.
Server-rendered CRUD
The routes follow the classic create / read / update / delete shape. Two rules recur: use get_or_404 so missing IDs return a clean 404, and check ownership before any mutation.
# app/books/routes.py (excerpt)
from flask import render_template, redirect, url_for, flash, request, abort
from flask_login import current_user, login_required
from app.extensions import db
from app.books import books_bp
from app.books.forms import BookForm
from app.models import Book
@books_bp.route("/")
def index():
page = request.args.get("page", 1, type=int)
books = db.paginate(db.select(Book).order_by(Book.title), page=page, per_page=12)
return render_template("books/index.html", title="All Books", books=books)
@books_bp.route("/create", methods=["GET", "POST"])
@login_required
def create():
form = BookForm()
if form.validate_on_submit():
book = Book(owner=current_user)
form.populate_obj(book) # copies matching fields onto the model
db.session.add(book)
db.session.commit()
flash("Book added.", "success")
return redirect(url_for("books.detail", book_id=book.id))
return render_template("books/form.html", title="Add Book", form=form)
@books_bp.route("/<int:book_id>")
def detail(book_id):
book = db.get_or_404(Book, book_id)
return render_template("books/detail.html", title=book.title, book=book)
@books_bp.route("/<int:book_id>/delete", methods=["POST"])
@login_required
def delete(book_id):
book = db.get_or_404(Book, book_id)
if book.owner != current_user: # ownership check
abort(403)
db.session.delete(book)
db.session.commit()
flash("Book deleted.", "success")
return redirect(url_for("books.index"))
Serialization schemas (Marshmallow)
Schemas turn model objects into JSON and validate incoming JSON. Nesting lets a book carry its reviews and owner inline.
# app/api/schemas.py
from marshmallow import fields, validate
from app.extensions import ma
from app.models import User, Book, Review
class UserSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = User
exclude = ("password_hash",)
class ReviewSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Review
include_fk = True
rating = fields.Integer(validate=validate.Range(min=1, max=5))
author = fields.Nested(UserSchema, only=("id", "username"), dump_only=True)
class BookSchema(ma.SQLAlchemyAutoSchema):
class Meta:
model = Book
include_fk = True
owner = fields.Nested(UserSchema, only=("id", "username"), dump_only=True)
reviews = fields.Nested(ReviewSchema, many=True, dump_only=True)
average_rating = fields.Float(dump_only=True)
book_schema = BookSchema()
books_schema = BookSchema(many=True)
The API resources
Using plain Flask views (no extra library needed), each endpoint dumps or loads through the schema. HTTP Basic auth guards writes; reads are public.
# app/api/routes.py (excerpt)
from flask import request, jsonify, g, abort
from flask_httpauth import HTTPBasicAuth
from marshmallow import ValidationError
from app.extensions import db
from app.api import api_bp
from app.api.schemas import book_schema, books_schema
from app.models import User, Book
auth = HTTPBasicAuth()
@auth.verify_password
def verify_password(username, password):
user = db.session.scalar(db.select(User).filter_by(username=username))
if user and user.check_password(password):
g.user = user
return user
return None
@api_bp.get("/books")
def list_books():
stmt = db.select(Book)
if author := request.args.get("author"):
stmt = stmt.where(Book.author.ilike(f"%{author}%"))
return books_schema.dump(db.session.scalars(stmt).all())
@api_bp.get("/books/<int:book_id>")
def get_book(book_id):
return book_schema.dump(db.get_or_404(Book, book_id))
@api_bp.post("/books")
@auth.login_required
def create_book():
try:
data = book_schema.load(request.get_json(), session=db.session)
except ValidationError as err:
return jsonify(errors=err.messages), 400
data.owner = g.user
db.session.add(data)
db.session.commit()
return book_schema.dump(data), 201
@api_bp.delete("/books/<int:book_id>")
@auth.login_required
def delete_book(book_id):
book = db.get_or_404(Book, book_id)
if book.owner != g.user:
abort(403)
db.session.delete(book)
db.session.commit()
return "", 204
Exercise the API with curl
# Public read
curl http://localhost:5000/api/books/1
# Authenticated create
curl -u ray:secret123 -X POST http://localhost:5000/api/books \
-H "Content-Type: application/json" \
-d '{"title":"Neuromancer","author":"William Gibson"}'
✅ Milestone 4 done when…
You can add, view, and delete a book in the browser, and do the same over the API with curl. Deleting someone else's book returns 403. Commit — you have shipped the core app.
The Build Checklist
Print this or keep it open. Tick each item as you go; the app should be runnable at every checkpoint.
☑️ Core (must finish)
- M1 — virtualenv + packages installed;
create_app()factory;mainblueprint; server starts. - M1 —
.envholdsSECRET_KEY;.gitignoreexcludes.venv/,.env,*.db; first commit made. - M2 — four typed models; relationships back-populate; migration created and upgraded.
- M2 — verified in
flask shell(create a user + book, read them back). - M3 — registration rejects duplicate username/email; passwords hashed; login/logout work;
nextvalidated. - M4 — book create/read/update/delete in the UI; ownership enforced (403 on others' books).
- M4 — Marshmallow schemas; GET/POST/DELETE API endpoints; Basic auth on writes; tested with
curl.
⭐ Stretch (if time allows)
- Reading lists: create lists and add/remove books (uses the many-to-many).
- Reviews: rate 1–5 and leave text; show an average on the book page.
- Search & filter by title/author/genre.
- A handful of
pytesttests using an in-memory SQLite database. - Deploy to a host (Render, Railway, or PythonAnywhere) with a production DB.
What Good Looks Like
Anyone can make code that runs once. A good weekend build has qualities you can point to. Use this as your "look back" (Pólya step 4) — grade your own project honestly against each row.
| Dimension | Just works | Good ✅ |
|---|---|---|
| Structure | Everything in one app.py | Factory + blueprints; models split by entity |
| Secrets | Secret key hard-coded | Loaded from .env; .env git-ignored |
| Models | Untyped db.Column | Typed Mapped[...]; nullability explicit |
| Schema changes | db.create_all() only | Flask-Migrate migrations under version control |
| Security | Plain passwords; no ownership checks | Hashed passwords; 403 on cross-user edits; next validated |
| Errors | 500s on bad input | get_or_404; validation returns 400 with messages |
| API | Ad-hoc dicts | Marshmallow schemas; correct status codes (201/204/400/403) |
✅ The one-sentence bar
A stranger can clone your repo, create a .env, run three commands (pip install -r requirements.txt, flask db upgrade, flask run), register an account, and add a book — without asking you a single question. If your README makes that true, your project is good.
Stretch Goals & Quiz
🏋️ Stretch challenge: reading lists
Objective: use the many-to-many relationship you already modeled. Add routes so a user can create a named list and add or remove books from it.
Steps:
- Add a
reading_listsblueprint withindex,create, anddetailroutes. - Add
add_book/remove_bookPOST routes keyed by list id and book id. - Guard against adding a book twice, and enforce list ownership.
💡 Hint
The relationship gives you a normal Python list. Adding is reading_list.books.append(book); removing is reading_list.books.remove(book); then db.session.commit(). Check if book in reading_list.books before appending to avoid duplicates.
✅ Reference for add_book
@reading_lists_bp.post("/<int:list_id>/add/<int:book_id>")
@login_required
def add_book(list_id, book_id):
rl = db.get_or_404(ReadingList, list_id)
book = db.get_or_404(Book, book_id)
if rl.owner != current_user:
abort(403)
if book in rl.books:
flash("Already in this list.", "info")
else:
rl.books.append(book)
db.session.commit()
flash("Added to list.", "success")
return redirect(url_for("reading_lists.detail", list_id=rl.id))
🎯 Quick Quiz
Question 1: Why does the project use the application-factory (create_app()) pattern instead of a single global app?
Question 2: In a SQLAlchemy 2.0 typed model, what does Mapped[str | None] declare compared to Mapped[str]?
Question 3: A logged-in user sends DELETE /api/books/9 for a book owned by someone else. What should the API do?
Summary
🎉 Key Takeaways
- Break big builds into milestones and keep the app runnable at each one — Pólya's method applied to shipping.
- The factory + blueprint pattern keeps a growing Flask app organized and testable.
- SQLAlchemy 2.0 typed models make schema intent explicit; Flask-Migrate versions every change.
- Security is not optional: hash passwords, check ownership, validate redirects and input.
- The same data can serve both HTML pages and a JSON API, with Marshmallow doing serialization and validation.
- "Good" is measurable — grade your build against the what-good-looks-like table.
📚 Further Reading
- Flask — official tutorial (Flaskr)
- SQLAlchemy 2.0 ORM quickstart (typed models)
- Flask-Migrate documentation
- Marshmallow — schema serialization & validation
🚀 What's Next?
You've built a complete backend with Flask, taking full control of every route, model, and query. Next, Module 18 introduces Django — a "batteries-included" framework that makes very different trade-offs. Building the same kinds of features there will sharpen your judgment about when to reach for a minimal framework versus a full one.
🎉 You shipped an app!
Push it to GitHub, write that README, and add it to your portfolio. This is the kind of project employers actually want to see.