PYTH-220 · Lesson 12 of 12 · 31 min

Capstone: a complete web application

▶ Video coming soon A video version of this lesson is on the way.

Over eleven lessons Jordan has learned how the web works, how WSGI connects servers to applications, how Flask and Django route requests, render templates, read forms, use a database, manage accounts, serve JSON and defend against the common attacks. In this capstone the pieces come together in one working application: a compact version of the Ridgeline web shop with a coffee list, a pickup order form with CSRF protection and validation, a race-free stock update in a transaction, a JSON stock API for Theo's phone, security headers on every response and a set of automated tests.

To keep everything runnable in your browser, the capstone uses only the standard library: the WSGI pattern from lesson 2, the router from lesson 3, escaping from lesson 4, validation from lesson 5, SQLite from lesson 6, CSRF tokens and headers from lesson 11. Every piece maps directly onto its Flask or Django equivalent, and the final sections show how the real shop is built and deployed with a framework.

The application

The whole application fits in about a hundred lines. Read it from top to bottom: settings, the database, a route decorator, helpers for building responses, four views, and the WSGI callable that ties them together. Each line should look familiar.

shop.py: the complete capstone application
# shop.py: the Ridgeline web shop as one WSGI application (standard library only)
import hashlib, hmac, html, json, re, sqlite3
from http import HTTPStatus
from urllib.parse import parse_qs

SECRET_KEY = b"set-from-the-environment-in-production"
SECURITY = [("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "strict-origin-when-cross-origin"),
            ("Content-Security-Policy", "default-src 'self'")]

def make_db():
    db = sqlite3.connect(":memory:")
    db.row_factory = sqlite3.Row
    db.executescript("""
        CREATE TABLE coffee (sku TEXT PRIMARY KEY, name TEXT, price_cents INTEGER, bags INTEGER CHECK (bags >= 0));
        CREATE TABLE orders (id INTEGER PRIMARY KEY, email TEXT, sku TEXT, quantity INTEGER);
        INSERT INTO coffee VALUES ('RC-101','Sunrise Blend',1850,42), ('RC-102','Ethiopia Guji',2200,15),
            ('RC-104','Sumatra Dark',2025,9), ('RC-106','Espresso Ridge',2150,30);""")
    return db

DB = make_db()
ROUTES = []

def route(pattern, methods=("GET",)):
    rx = re.compile(re.sub(r"<(\w+)>", r"(?P<\1>[^/]+)", pattern))
    def register(view):
        ROUTES.append((rx, set(methods), view))
        return view
    return register

def csrf_token(session):
    return hmac.new(SECRET_KEY, session.encode(), hashlib.sha256).hexdigest()[:32]

def respond(status, body, ctype="text/html; charset=utf-8", extra=()):
    data = body.encode("utf-8") if isinstance(body, str) else body
    return status, [("Content-Type", ctype), ("Content-Length", str(len(data)))] + list(extra), data

def page(title, content):
    return respond(200, f"<!doctype html><title>{html.escape(title)} | Ridgeline</title><main>{content}</main>")

def as_json(data, status=200, extra=()):
    return respond(status, json.dumps(data), "application/json", extra)

@route("/")
def home(req):
    rows = DB.execute("SELECT sku, name, price_cents, bags FROM coffee ORDER BY sku").fetchall()
    items = "".join(f"<li>{html.escape(r['name'])} {r['price_cents'] / 100:.2f}"
                    f"{' (only ' + str(r['bags']) + ' left)' if r['bags'] < 10 else ''}</li>" for r in rows)
    return page("Coffees", f"<h1>Our coffees</h1><ul>{items}</ul>")

@route("/order", methods=("POST",))
def order(req):
    form = req["form"]
    if not hmac.compare_digest(form.get("csrf_token", ""), csrf_token(req["session"])):
        return respond(403, "Form expired, please try again")
    sku, email = form.get("sku", ""), form.get("email", "").strip().lower()
    try:
        qty = int(form.get("quantity", ""))
    except ValueError:
        qty = 0
    if not 1 <= qty <= 20 or "@" not in email:
        return respond(422, "Please check the quantity and email")
    with DB:
        cur = DB.execute("UPDATE coffee SET bags = bags - ? WHERE sku = ? AND bags >= ?", (qty, sku, qty))
        if cur.rowcount == 0:
            return respond(409, "Sorry, not enough of that coffee")
        oid = DB.execute("INSERT INTO orders (email, sku, quantity) VALUES (?, ?, ?)", (email, sku, qty)).lastrowid
    return respond(303, "", extra=[("Location", f"/orders/{oid}")])

@route("/api/stock")
def api_stock(req):
    rows = DB.execute("SELECT sku, bags FROM coffee ORDER BY sku").fetchall()
    return as_json({r["sku"]: r["bags"] for r in rows})

def app(environ, start_response):
    method, path = environ.get("REQUEST_METHOD", "GET"), environ.get("PATH_INFO", "/")
    body = environ.get("wsgi.input_text", "")       # real servers give a wsgi.input stream; the test client passes text
    req = {"method": method, "form": {k: v[0] for k, v in parse_qs(body).items()},
           "session": environ.get("HTTP_COOKIE", "session=anon").split("session=")[-1].split(";")[0]}
    result, allowed = None, set()
    for rx, methods, view in ROUTES:
        m = rx.fullmatch(path)
        if m:
            if method in methods:
                result = view(req, **m.groupdict())
                break
            allowed |= methods
    if result is None:
        result = respond(405, "Method not allowed", extra=[("Allow", ", ".join(sorted(allowed)))]) if allowed else respond(404, "Not found")
    code, headers, data = result
    s = HTTPStatus(code)
    start_response(f"{s.value} {s.phrase}", headers + SECURITY)
    return [data]

A few details are worth pointing out. Every value that reaches SQL is a ? parameter. Every name that reaches HTML goes through html.escape, so a coffee named with markup cannot inject anything. The order view checks the CSRF token first, validates the fields, then subtracts stock and inserts the order inside one transaction with the race-free UPDATE, and finally redirects with 303 to the order page (Post/Redirect/Get). Unknown paths get 404 and wrong methods get 405 with an Allow header. The security headers are added in one place, in app, so no view can forget them.

A real deployment would read SECRET_KEY from the environment, use a database file or server rather than memory, and give each request its own connection. Those changes affect configuration, not structure, which is a sign the design is sound.

Architecture of the capstone. A browser and a phone app send requests to the WSGI app. Inside, the router sends GET / to the home view, POST /order to the order view and GET /api/stock to the stock API. The order view checks the CSRF token, validates the form and runs a transaction against the SQLite coffee and orders tables. Every response passes through a layer that adds security headers before returning to the client. A test client on the side calls the app directly.
The capstone: router, views, database and security headers in one WSGI app

Testing the shop

An application without tests is a liability: the next change might break checkout and nobody would know until a customer complained. Because the shop is a WSGI callable, the test client is just a function that builds an environ and calls app, exactly like Flask's app.test_client() and Django's Client do. The scripted session below runs the app through the important paths, happy and unhappy, and prints what each request returned.

A scripted test session against the capstone
# shop.py: the Ridgeline web shop as one WSGI application (standard library only)
import hashlib, hmac, html, json, re, sqlite3
from http import HTTPStatus
from urllib.parse import parse_qs

SECRET_KEY = b"set-from-the-environment-in-production"
SECURITY = [("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "strict-origin-when-cross-origin"),
            ("Content-Security-Policy", "default-src 'self'")]

def make_db():
    db = sqlite3.connect(":memory:")
    db.row_factory = sqlite3.Row
    db.executescript("""
        CREATE TABLE coffee (sku TEXT PRIMARY KEY, name TEXT, price_cents INTEGER, bags INTEGER CHECK (bags >= 0));
        CREATE TABLE orders (id INTEGER PRIMARY KEY, email TEXT, sku TEXT, quantity INTEGER);
        INSERT INTO coffee VALUES ('RC-101','Sunrise Blend',1850,42), ('RC-102','Ethiopia Guji',2200,15),
            ('RC-104','Sumatra Dark',2025,9), ('RC-106','Espresso Ridge',2150,30);""")
    return db

DB = make_db()
ROUTES = []

def route(pattern, methods=("GET",)):
    rx = re.compile(re.sub(r"<(\w+)>", r"(?P<\1>[^/]+)", pattern))
    def register(view):
        ROUTES.append((rx, set(methods), view))
        return view
    return register

def csrf_token(session):
    return hmac.new(SECRET_KEY, session.encode(), hashlib.sha256).hexdigest()[:32]

def respond(status, body, ctype="text/html; charset=utf-8", extra=()):
    data = body.encode("utf-8") if isinstance(body, str) else body
    return status, [("Content-Type", ctype), ("Content-Length", str(len(data)))] + list(extra), data

def page(title, content):
    return respond(200, f"<!doctype html><title>{html.escape(title)} | Ridgeline</title><main>{content}</main>")

def as_json(data, status=200, extra=()):
    return respond(status, json.dumps(data), "application/json", extra)

@route("/")
def home(req):
    rows = DB.execute("SELECT sku, name, price_cents, bags FROM coffee ORDER BY sku").fetchall()
    items = "".join(f"<li>{html.escape(r['name'])} {r['price_cents'] / 100:.2f}"
                    f"{' (only ' + str(r['bags']) + ' left)' if r['bags'] < 10 else ''}</li>" for r in rows)
    return page("Coffees", f"<h1>Our coffees</h1><ul>{items}</ul>")

@route("/order", methods=("POST",))
def order(req):
    form = req["form"]
    if not hmac.compare_digest(form.get("csrf_token", ""), csrf_token(req["session"])):
        return respond(403, "Form expired, please try again")
    sku, email = form.get("sku", ""), form.get("email", "").strip().lower()
    try:
        qty = int(form.get("quantity", ""))
    except ValueError:
        qty = 0
    if not 1 <= qty <= 20 or "@" not in email:
        return respond(422, "Please check the quantity and email")
    with DB:
        cur = DB.execute("UPDATE coffee SET bags = bags - ? WHERE sku = ? AND bags >= ?", (qty, sku, qty))
        if cur.rowcount == 0:
            return respond(409, "Sorry, not enough of that coffee")
        oid = DB.execute("INSERT INTO orders (email, sku, quantity) VALUES (?, ?, ?)", (email, sku, qty)).lastrowid
    return respond(303, "", extra=[("Location", f"/orders/{oid}")])

@route("/api/stock")
def api_stock(req):
    rows = DB.execute("SELECT sku, bags FROM coffee ORDER BY sku").fetchall()
    return as_json({r["sku"]: r["bags"] for r in rows})

def app(environ, start_response):
    method, path = environ.get("REQUEST_METHOD", "GET"), environ.get("PATH_INFO", "/")
    body = environ.get("wsgi.input_text", "")       # real servers give a wsgi.input stream; the test client passes text
    req = {"method": method, "form": {k: v[0] for k, v in parse_qs(body).items()},
           "session": environ.get("HTTP_COOKIE", "session=anon").split("session=")[-1].split(";")[0]}
    result, allowed = None, set()
    for rx, methods, view in ROUTES:
        m = rx.fullmatch(path)
        if m:
            if method in methods:
                result = view(req, **m.groupdict())
                break
            allowed |= methods
    if result is None:
        result = respond(405, "Method not allowed", extra=[("Allow", ", ".join(sorted(allowed)))]) if allowed else respond(404, "Not found")
    code, headers, data = result
    s = HTTPStatus(code)
    start_response(f"{s.value} {s.phrase}", headers + SECURITY)
    return [data]

def call(method, path, form=None, session="anon"):
    """A tiny test client: call the WSGI app like a server would."""
    from urllib.parse import urlencode
    env = {"REQUEST_METHOD": method, "PATH_INFO": path, "wsgi.input_text": urlencode(form or {}),
           "HTTP_COOKIE": f"session={session}"}
    out = {}
    def start_response(status, headers):
        out["status"], out["headers"] = status, dict(headers)
    body = b"".join(app(env, start_response)).decode("utf-8")
    return out["status"], out["headers"], body


token = csrf_token("sam")
print(call("GET", "/")[0])
print(call("GET", "/api/stock")[2])
status, headers, _ = call("POST", "/order", {"sku": "RC-104", "quantity": "3", "email": "Sam@Example.com", "csrf_token": token}, "sam")
print(status, headers.get("Location"))
print(call("POST", "/order", {"sku": "RC-104", "quantity": "10", "email": "sam@example.com", "csrf_token": token}, "sam")[0])
print(call("POST", "/order", {"sku": "RC-104", "quantity": "1", "email": "sam@example.com", "csrf_token": "forged"}, "sam")[0])
print(call("POST", "/order", {"sku": "RC-104", "quantity": "lots", "email": "sam@example.com", "csrf_token": token}, "sam")[0])
print(call("GET", "/api/stock")[2])
print(call("DELETE", "/api/stock")[0], call("GET", "/nowhere")[0])
print(call("GET", "/")[1]["X-Content-Type-Options"])
Output
200 OK
{"RC-101": 42, "RC-102": 15, "RC-104": 9, "RC-106": 30}
303 See Other /orders/1
409 Conflict
403 Forbidden
422 Unprocessable Content
{"RC-101": 42, "RC-102": 15, "RC-104": 6, "RC-106": 30}
405 Method Not Allowed 404 Not Found
nosniff

The session shows every important behaviour: the first order of three bags of Sumatra Dark succeeds with a 303 redirect, the second order of ten fails with 409 because only six bags remain, a forged CSRF token is refused with 403, a quantity of lots is refused with 422, the stock API reflects the one successful order, and wrong methods and unknown paths get 405 and 404. Turning a session like this into proper tests means writing each check as an assertion, so a test runner can report failures automatically, which you will do in depth in PYTH-230.

The same checks written as test functions
# shop.py: the Ridgeline web shop as one WSGI application (standard library only)
import hashlib, hmac, html, json, re, sqlite3
from http import HTTPStatus
from urllib.parse import parse_qs

SECRET_KEY = b"set-from-the-environment-in-production"
SECURITY = [("X-Content-Type-Options", "nosniff"), ("Referrer-Policy", "strict-origin-when-cross-origin"),
            ("Content-Security-Policy", "default-src 'self'")]

def make_db():
    db = sqlite3.connect(":memory:")
    db.row_factory = sqlite3.Row
    db.executescript("""
        CREATE TABLE coffee (sku TEXT PRIMARY KEY, name TEXT, price_cents INTEGER, bags INTEGER CHECK (bags >= 0));
        CREATE TABLE orders (id INTEGER PRIMARY KEY, email TEXT, sku TEXT, quantity INTEGER);
        INSERT INTO coffee VALUES ('RC-101','Sunrise Blend',1850,42), ('RC-102','Ethiopia Guji',2200,15),
            ('RC-104','Sumatra Dark',2025,9), ('RC-106','Espresso Ridge',2150,30);""")
    return db

DB = make_db()
ROUTES = []

def route(pattern, methods=("GET",)):
    rx = re.compile(re.sub(r"<(\w+)>", r"(?P<\1>[^/]+)", pattern))
    def register(view):
        ROUTES.append((rx, set(methods), view))
        return view
    return register

def csrf_token(session):
    return hmac.new(SECRET_KEY, session.encode(), hashlib.sha256).hexdigest()[:32]

def respond(status, body, ctype="text/html; charset=utf-8", extra=()):
    data = body.encode("utf-8") if isinstance(body, str) else body
    return status, [("Content-Type", ctype), ("Content-Length", str(len(data)))] + list(extra), data

def page(title, content):
    return respond(200, f"<!doctype html><title>{html.escape(title)} | Ridgeline</title><main>{content}</main>")

def as_json(data, status=200, extra=()):
    return respond(status, json.dumps(data), "application/json", extra)

@route("/")
def home(req):
    rows = DB.execute("SELECT sku, name, price_cents, bags FROM coffee ORDER BY sku").fetchall()
    items = "".join(f"<li>{html.escape(r['name'])} {r['price_cents'] / 100:.2f}"
                    f"{' (only ' + str(r['bags']) + ' left)' if r['bags'] < 10 else ''}</li>" for r in rows)
    return page("Coffees", f"<h1>Our coffees</h1><ul>{items}</ul>")

@route("/order", methods=("POST",))
def order(req):
    form = req["form"]
    if not hmac.compare_digest(form.get("csrf_token", ""), csrf_token(req["session"])):
        return respond(403, "Form expired, please try again")
    sku, email = form.get("sku", ""), form.get("email", "").strip().lower()
    try:
        qty = int(form.get("quantity", ""))
    except ValueError:
        qty = 0
    if not 1 <= qty <= 20 or "@" not in email:
        return respond(422, "Please check the quantity and email")
    with DB:
        cur = DB.execute("UPDATE coffee SET bags = bags - ? WHERE sku = ? AND bags >= ?", (qty, sku, qty))
        if cur.rowcount == 0:
            return respond(409, "Sorry, not enough of that coffee")
        oid = DB.execute("INSERT INTO orders (email, sku, quantity) VALUES (?, ?, ?)", (email, sku, qty)).lastrowid
    return respond(303, "", extra=[("Location", f"/orders/{oid}")])

@route("/api/stock")
def api_stock(req):
    rows = DB.execute("SELECT sku, bags FROM coffee ORDER BY sku").fetchall()
    return as_json({r["sku"]: r["bags"] for r in rows})

def app(environ, start_response):
    method, path = environ.get("REQUEST_METHOD", "GET"), environ.get("PATH_INFO", "/")
    body = environ.get("wsgi.input_text", "")       # real servers give a wsgi.input stream; the test client passes text
    req = {"method": method, "form": {k: v[0] for k, v in parse_qs(body).items()},
           "session": environ.get("HTTP_COOKIE", "session=anon").split("session=")[-1].split(";")[0]}
    result, allowed = None, set()
    for rx, methods, view in ROUTES:
        m = rx.fullmatch(path)
        if m:
            if method in methods:
                result = view(req, **m.groupdict())
                break
            allowed |= methods
    if result is None:
        result = respond(405, "Method not allowed", extra=[("Allow", ", ".join(sorted(allowed)))]) if allowed else respond(404, "Not found")
    code, headers, data = result
    s = HTTPStatus(code)
    start_response(f"{s.value} {s.phrase}", headers + SECURITY)
    return [data]

def call(method, path, form=None, session="anon"):
    """A tiny test client: call the WSGI app like a server would."""
    from urllib.parse import urlencode
    env = {"REQUEST_METHOD": method, "PATH_INFO": path, "wsgi.input_text": urlencode(form or {}),
           "HTTP_COOKIE": f"session={session}"}
    out = {}
    def start_response(status, headers):
        out["status"], out["headers"] = status, dict(headers)
    body = b"".join(app(env, start_response)).decode("utf-8")
    return out["status"], out["headers"], body


def test_home_lists_coffees():
    status, headers, body = call("GET", "/")
    assert status == "200 OK"
    assert "Sumatra Dark" in body and "only 9 left" in body

def test_order_needs_csrf():
    status, _, _ = call("POST", "/order", {"sku": "RC-101", "quantity": "1", "email": "a@b.com", "csrf_token": "x"})
    assert status.startswith("403")

def test_security_headers_everywhere():
    for path in ("/", "/api/stock", "/missing"):
        assert call("GET", path)[1]["X-Content-Type-Options"] == "nosniff"

passed = 0
for test in (test_home_lists_coffees, test_order_needs_csrf, test_security_headers_everywhere):
    test()
    passed += 1
    print("passed:", test.__name__)
print(passed, "tests passed")
Output
passed: test_home_lists_coffees
passed: test_order_needs_csrf
passed: test_security_headers_everywhere
3 tests passed

Try it yourself

Add a coffee API endpoint

The capstone app and its test client call are loaded in the setup. Add a route GET /api/coffees/<sku> that returns JSON {"sku": ..., "name": ..., "bags": ...} with status 200 for a known coffee, and JSON {"error": "not_found"} with status 404 for an unknown one.

Use the @route decorator, a ? parameter for the query and the as_json(data, status) helper. The checks call your endpoint through call.

@route('/api/coffees/<sku>')
def api_coffee(req, sku):
    return as_json({'sku': sku})

print(call('GET', '/api/coffees/RC-104'))

From the capstone to Flask and Django

The real Ridgeline shop is built with a framework, and every part of the capstone has a direct counterpart. The route decorator becomes @app.get or a Django path(). respond and page become render_template or render with templates that extend base.html. The hand-written CSRF check becomes Flask-WTF or Django's built-in middleware with {% csrf_token %}. The database helpers become a connection per request or the Django ORM, with migrations for every schema change. The security header list becomes Django's SecurityMiddleware settings or a Flask after_request hook. The call function becomes the framework's test client, run by pytest.

The capstone's order logic as a Django view
# The order view in Django (orders/views.py), on your own computer
from django.contrib import messages
from django.db import transaction
from django.db.models import F
from django.shortcuts import redirect, render
from catalog.models import Coffee
from .forms import OrderForm
from .models import Order, OrderLine

def place_order(request):
    form = OrderForm(request.POST or None)              # validation rules live in the form
    if request.method == "POST" and form.is_valid():
        data = form.cleaned_data
        with transaction.atomic():
            updated = Coffee.objects.filter(sku=data["sku"], bags__gte=data["quantity"]).update(bags=F("bags") - data["quantity"])
            if not updated:
                form.add_error("quantity", "Sorry, not enough of that coffee.")
                return render(request, "orders/place.html", {"form": form}, status=409)
            coffee = Coffee.objects.get(sku=data["sku"])
            order = Order.objects.create(email=data["email"])
            OrderLine.objects.create(order=order, coffee=coffee, quantity=data["quantity"], price=coffee.price)
        messages.success(request, "Thanks! We'll email you when it's ready.")
        return redirect("orders:confirmed", order_id=order.id)
    return render(request, "orders/place.html", {"form": form}, status=422 if request.method == "POST" else 200)

Deployment

Putting the shop on the internet means running the same WSGI application with production settings, behind a production web server, with care for secrets, static files, the database and monitoring. A typical setup for a small business like Ridgeline is a single virtual server: nginx or IIS in front handling HTTPS and serving the static files directly, Gunicorn or Waitress running the Python application with several workers, PostgreSQL for the database, and a process manager such as systemd (or a Windows service) that restarts the application if it stops. Managed platforms can handle much of this for you at a higher monthly cost.

Typical deployment commands for the Django shop
> python manage.py check --deploy          (Django lists insecure settings)
> python manage.py collectstatic            (gathers CSS, images and scripts for the web server)
> python manage.py migrate                  (applies new migrations)
> gunicorn ridgeline_site.wsgi:application --workers 3 --bind 127.0.0.1:8000
  (on Windows:  waitress-serve --listen=127.0.0.1:8000 ridgeline_site.wsgi:application)

Before launch, Jordan works through a checklist, most of which you have met in this course. Debug mode is off. SECRET_KEY, database credentials and API keys come from environment variables. ALLOWED_HOSTS is set. HTTPS works, with automatic certificate renewal, and HSTS is on. Security headers and CSRF protection are active. Static files are served by the web server with long cache times. The database is backed up automatically every night, and a restore has actually been tested, because a backup nobody has restored is only a hope. Errors are reported to Jordan, with an error-tracking service or at least email, and logs are kept for sign-ins and admin actions. Dependencies are audited and a routine is in place for applying updates.

A small pre-launch settings check
CHECKS = {
    "DEBUG is off": lambda s: s.get("DEBUG") is False,
    "secret key set and not the default": lambda s: len(s.get("SECRET_KEY", "")) >= 32 and "dev" not in s.get("SECRET_KEY", ""),
    "allowed hosts listed": lambda s: bool(s.get("ALLOWED_HOSTS")),
    "HTTPS only cookies": lambda s: s.get("SESSION_COOKIE_SECURE") and s.get("CSRF_COOKIE_SECURE"),
    "HSTS on": lambda s: s.get("SECURE_HSTS_SECONDS", 0) >= 31536000,
}

settings = {"DEBUG": False, "SECRET_KEY": "k" * 50, "ALLOWED_HOSTS": ["shop.ridgeline.example"],
            "SESSION_COOKIE_SECURE": True, "CSRF_COOKIE_SECURE": False, "SECURE_HSTS_SECONDS": 31536000}
for name, check in CHECKS.items():
    print(("ok    " if check(settings) else "FIX   ") + name)
Output
ok    DEBUG is off
ok    secret key set and not the default
ok    allowed hosts listed
FIX   HTTPS only cookies
ok    HSTS on

Try it yourself

Write a test for the order rules

The capstone app, call and csrf_token are loaded in the setup. Write a function order_status(sku, quantity, email, token_ok=True) that posts an order through call for session "tester", using csrf_token("tester") as the token when token_ok is true and "bad" otherwise, and returns just the status code as an int (for example 303).

The checks then use your helper to confirm the shop's rules.

def order_status(sku, quantity, email, token_ok=True):
    s, h, b = call('POST', '/order', {'sku': sku, 'quantity': quantity, 'email': email})
    return s

print(order_status('RC-101', 1, 'a@b.com'))

Where to go next

You have now seen web development from the wire up. The next courses build on it. PYTH-230 turns scripted checks like the capstone's into a proper test suite with pytest, fixtures and coverage. PYTH-240 connects programs to other services' APIs and automates work around the shop. PYTH-260 covers packaging and deployment pipelines in depth, and the program's capstone project, PYTH-290, is a complete application you design and build yourself, where the web skills from this course are likely to be at the centre.

Rosa's verdict on the first week of online pickup orders was simple: fewer phone calls, no mistakes in orders, and Theo checking stock from the depot instead of driving back to look. That is what good web software does. It takes a real process, makes it safe and reliable, and gets out of the way.

Try it yourself

A low-stock page

Add a route GET /low to the capstone that returns an HTML page (using the page(title, content) helper) listing, as <li> items, the escaped names of coffees with fewer than 20 bags, ordered by name, inside a <ul>. If none are low, the content should include the text All stocked up.

Then the checks call /low through call.

@route('/low')
def low(req):
    return page('Low stock', '<ul></ul>')

print(call('GET', '/low')[2])

Common mistakes

  • MistakeAdding security features to some views and forgetting others.

    InsteadApply headers, CSRF checks and error handling in one central place.

  • MistakeTesting only the happy path.

    InsteadTest rejections too: bad tokens, invalid values, missing items and wrong methods.

  • MistakeDeploying with development settings.

    InsteadRun the deploy checks; set DEBUG off, secrets from the environment and HTTPS everywhere.

  • MistakeTaking backups without ever restoring one.

    InsteadSchedule backups and test a restore regularly.

  • MistakeServing static files through Python in production.

    InsteadLet nginx, IIS or a CDN serve them with long cache times.

  • MistakeLaunching without a way to hear about errors.

    InsteadSet up error reporting and keep logs of sign-ins and admin actions.

Summary and key terms

  • The capstone combines WSGI, routing, escaping, validation, SQLite transactions, CSRF tokens, security headers and JSON in one application.
  • Central handling of headers and errors keeps every view protected.
  • Because a web app is a WSGI callable, a test client can call it directly and check status codes, headers and bodies.
  • Each capstone piece maps onto Flask or Django features: route decorators, templates, forms, ORM, middleware and test clients.
  • Deployment runs the same app with production settings behind nginx or IIS, with Gunicorn or Waitress, a real database and a process manager.
  • A launch checklist covers debug off, secrets, hosts, HTTPS and HSTS, static files, tested backups, error reporting and updates.
test client
A helper that sends requests to an application in tests without a network.
deployment
Putting an application on a server where users can reach it.
process manager
A program, such as systemd, that starts the application and restarts it if it stops.
static files
Files such as CSS, images and scripts served as they are.
collectstatic
The Django command that gathers static files for the web server.
check --deploy
The Django command that reports insecure production settings.
error reporting
Sending details of server errors to developers automatically.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. In the capstone session, why does the second order of 10 bags fail with 409?

    Show answer

    Only 6 bags of Sumatra Dark remain after the first order of 3, so the race-free UPDATE changes no rows.

  2. Where are the security headers added, and why there?

    Show answer

    In the app function, so every response gets them and no view can forget.

  3. What Django command reports insecure settings before launch?

    Show answer

    python manage.py check --deploy.

  4. Why should nginx or IIS serve the static files?

    Show answer

    They serve files far more efficiently than Python and can set long cache times.

  5. Name three items on a launch checklist.

    Show answer

    Debug off, secrets in the environment, HTTPS with HSTS, tested backups, error reporting, dependency updates.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz