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 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.
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.
# 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"])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
nosniffThe session shows every important behavior: 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.
# 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")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 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.
> 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.
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)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 center.
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.
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.
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.
What Django command reports insecure settings before launch?
Show answer
python manage.py check --deploy.
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.
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.
Create a free account to save your progress, take quizzes and earn a certificate.