Skip to main content

🛠️ 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:

  1. Understand the problem — get crisp on what you're building and for whom.
  2. Devise a plan — break the whole into small, orderable milestones.
  3. Carry out the plan — build one milestone at a time, verifying as you go.
  4. Look back — test, refactor, and compare against a quality bar.
flowchart LR A["Understand
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:

QuestionAnswer 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.

Bookshelf entity relationships User connects one-to-many to Book, ReadingList, and Review. Book connects one-to-many to Review, and many-to-many to ReadingList. User accounts & login Book title, author, isbn… ReadingList named collections Review rating + text 1 : many 1 : many 1 : many many : many 1 book : many reviews
Figure 1 — Bookshelf's four entities. Solid lines are one-to-many; the Book↔ReadingList link is many-to-many (it needs a join table).

📖 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.

flowchart TD M1["M1 · Scaffold
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"]
WhenMilestonesYou should be able to…
Saturday AMM1 + M2Start the server; create tables; open a shell and add a Book.
Saturday PMM3Register an account, log in, and log out in the browser.
SundayM4Add/edit/delete/review books in the UI and via curl.
BonusStretchReading 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; main blueprint; server starts.
  • M1.env holds SECRET_KEY; .gitignore excludes .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; next validated.
  • 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 pytest tests 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.

DimensionJust worksGood ✅
StructureEverything in one app.pyFactory + blueprints; models split by entity
SecretsSecret key hard-codedLoaded from .env; .env git-ignored
ModelsUntyped db.ColumnTyped Mapped[...]; nullability explicit
Schema changesdb.create_all() onlyFlask-Migrate migrations under version control
SecurityPlain passwords; no ownership checksHashed passwords; 403 on cross-user edits; next validated
Errors500s on bad inputget_or_404; validation returns 400 with messages
APIAd-hoc dictsMarshmallow 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:

  1. Add a reading_lists blueprint with index, create, and detail routes.
  2. Add add_book / remove_book POST routes keyed by list id and book id.
  3. 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

🚀 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.