LEARN PYTHON | 09 FLASK WEB APPS
Build it. Test it. Explain it.
Build useful web responses and test the rules behind every accepted write. Work independently before opening a hint, then compare your tested solution with one possible approach.
YOUR CHALLENGE ROUTE
Complete these tasks in your Python editor. The page is a challenge guide, not a general Python runner. Use fictional details where helpful. Work in order or choose a task matching an area you need to practise.
Install Flask in a clean virtual environment. Samples needing app.py run in the learning-lab folder; samples importing portal or test_portal run in the portal folder. Download links are in Lessons 09.2 and 09.6. This page does not execute Python. Keep the development debugger local and use fictional accounts.
TRACK YOUR TESTED SOLUTIONS
Tick a task only after you have run it, checked at least two relevant cases and explained one line. These are self-recorded checks, not an automatic assessment of your source code.
Progress lasts while this page is open. Save your actual Python files in your project folder.
01 | ROUTES
BUILD AND PROBE A ROUTE MAP
Create route_demo.py with a home page, named greeting, project lookup and JSON status. Predict what each request returns before testing.
YOUR REQUIREMENTS
- Run the development server on loopback only.
- Define GET routes using decorators.
- Check project existence before returning it.
- Escape the greeting name.
- Inspect response status and content.
- Test a disallowed POST directly.
Hint: where to begin
Use a handler for each resource. The integer converter parses an ID; checking projects decides whether the record exists.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| GET / | 200, HTML home heading |
| GET /projects/1 | 200, intended project |
| GET /projects/99 | 404 |
| POST /projects/1 | 405 |
Reveal one possible solution and explanation
from flask import Flask, abort
from markupsafe import escape
app = Flask(__name__)
projects = {1: "Robot controller", 2: "Study planner"}
@app.get("/")
def home():
return "<h1>My first Flask application</h1>"
@app.get("/hello/<name>")
def hello(name):
return f"<p>Hello, {escape(name)}!</p>"
@app.get("/projects/<int:project_id>")
def project(project_id):
if project_id not in projects:
abort(404)
return {"id": project_id, "title": projects[project_id]}
@app.get("/api/status")
def status():
return {"service": "learning", "ready": True}
# Run these assertions with the app defined above.
client = app.test_client()
assert client.get("/").status_code == 200
assert client.get("/projects/1").json["title"] == "Robot controller"
assert client.get("/projects/99").status_code == 404
assert client.post("/projects/1").status_code == 405
print("Route contracts checked")The test client reaches handlers without a live browser. Assertions check both the expected record and failure contracts. Save this complete source, then use python -m flask --app route_demo run --debug for manual local browsing.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a second known project and a request test proving its distinct title.
02 | RESPONSES
SEPARATE HTML AND JSON RESPONSES
Create two routes for the same fictional data: one safe HTML text report and one machine-readable JSON response.
YOUR REQUIREMENTS
- Keep a fixed collection of project titles.
- Use a GET route for each representation.
- Escape titles inserted into HTML.
- Return a dictionary for JSON.
- Test content type and body independently.
Hint: where to begin
An HTML response and a JSON response can carry related facts while serving different clients.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| HTML report | List with escaped angle brackets |
| JSON endpoint | count is 2 and titles are structured data |
| Unknown route | 404 |
Reveal one possible solution and explanation
from flask import Flask
from markupsafe import escape
app = Flask(__name__)
titles = ["Robot controller", "<Study planner>"]
@app.get("/report")
def report():
items = "".join(f"<li>{escape(title)}</li>" for title in titles)
return f"<h1>Projects</h1><ul>{items}</ul>"
@app.get("/api/projects")
def project_data():
return {"titles": titles, "count": len(titles)}
client = app.test_client()
html = client.get("/report")
assert html.mimetype == "text/html"
assert b"<Study planner>" in html.data
assert client.get("/api/projects").json["count"] == 2
print("Two representations checked")This small route escapes interpolated text directly; larger pages use templates. JSON serialisation is not the same operation as rendering HTML.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a test that verifies both titles are present in the JSON in their original text form.
03 | TEMPLATES
RENDER A COLLECTION AND AN EMPTY STATE
Render two fictional tasks and a deliberate message when the collection is empty. Explain expression and statement delimiters.
YOUR REQUIREMENTS
- Supply a list of dictionaries from the view.
- Repeat an HTML list item with Jinja.
- Show a title and minutes for each item.
- Include for-else output.
- Test empty and nonempty collections.
Hint: where to begin
The loop statement determines repetition; {{ }} inserts a value. The following complete compact version keeps its template in a string for testing.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Two supplied tasks | Both titles and durations displayed |
| Empty collection | No tasks message |
| Title with markup | Escaped rather than active HTML |
Reveal one possible solution and explanation
from flask import Flask, render_template_string
app = Flask(__name__)
@app.get("/tasks")
def tasks():
records = [{"title": "Trace loops", "minutes": 25}]
template = """<h1>Study tasks</h1><ul>
{% for task in tasks %}
<li>{{ task['title'] }}: {{ task['minutes'] }} minutes</li>
{% else %}
<li>No tasks yet.</li>
{% endfor %}</ul>"""
return render_template_string(template, tasks=records)
from flask import render_template_string
client = app.test_client()
assert b"Trace loops" in client.get("/tasks").data
with app.app_context():
empty = render_template_string("{% for task in tasks %}{{ task.title }}{% else %}No tasks{% endfor %}", tasks=[])
assert empty == "No tasks"
print("Collection and empty state checked")The short string template is executable as shown. The lab download demonstrates separate templates and inheritance for actual pages.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Move the template into templates/tasks.html and keep the same data and empty-state contracts.
04 | OUTPUT CONTEXT
PROVE USER TEXT STAYS TEXT
Write an automated check that a title containing markup appears as text and not an active element. Then add a harmless stylesheet through url_for in the lab.
YOUR REQUIREMENTS
- Use default Jinja escaping.
- Test a script-like fictional title.
- Avoid the safe filter.
- Put CSS in static.
- Link it using the static endpoint.
- Verify the linked CSS request returns 200.
Hint: where to begin
Escaping is tested in the output context. CSS delivery is a different concern from rendering user input.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Markup-like title | Escaped output, no script element |
| Ordinary title | Same readable title |
| Lab /static/lab.css | 200 stylesheet response |
Reveal one possible solution and explanation
from flask import Flask, render_template_string
app = Flask(__name__)
with app.app_context():
html = render_template_string("<h1>{{ title }}</h1>", title="<script>fictional text</script>")
assert "<script>" not in html
assert "<script>" in html
print("Title rendered as text")
# In the lab base.html, the stylesheet link is:
# <link rel="stylesheet" href="{{ url_for('static', filename='lab.css') }}">The supplied Python checks the title boundary. The commented template line belongs in HTML, not in a Python route. The lab already includes this structure for comparison.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Test an apostrophe and an ampersand without stripping meaningful title characters.
05 | FORMS AND STORAGE
SAVE A VALID TASK WITHOUT TRUSTING THE BROWSER
Use the learning lab to submit accepted and rejected tasks through direct requests. Prove invalid minutes do not add a record.
YOUR REQUIREMENTS
- Run this sample beside app.py in the lab folder.
- Use a temporary database.
- Obtain the current form token.
- Submit one valid task.
- Send zero, 241 and nonnumeric minutes directly.
- Compare the stored summary before and after rejection.
Hint: where to begin
The server checks actual form values. Do not disable its token check in tests.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| 25 minutes | 303, one stored task |
| 0 and 241 | 400, earlier row preserved |
| text | 400, no added record |
Reveal one possible solution and explanation
from pathlib import Path
from tempfile import TemporaryDirectory
from app import create_app
with TemporaryDirectory() as folder:
app = create_app({"TESTING": True, "SECRET_KEY": "test-only-" + "x" * 64, "DATABASE": str(Path(folder) / "tasks.sqlite3")})
client = app.test_client()
client.get("/tasks/new")
with client.session_transaction() as session:
token = session["csrf"]
data = {"csrf": token, "title": "Trace a loop", "minutes": "25"}
assert client.post("/tasks/new", data=data).status_code == 303
before = client.get("/api/summary").json
for bad in ["0", "241", "text"]:
assert client.post("/tasks/new", data={**data, "minutes": bad}).status_code == 400
assert client.get("/api/summary").json == before
print("Accepted save and rejected writes checked")The isolated app writes only a temporary database. The comparison checks actual saved effects, not just whether an error message appeared.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add blank-title and overlong-title cases while preserving the accepted task.
06 | SQL AND TRANSACTIONS
TEST PARAMETERS AND PERSISTENCE
Store an apostrophe and SQL-looking title through bound parameters, then reopen the file and verify both remain data.
YOUR REQUIREMENTS
- Create a temporary SQLite path.
- Use placeholders for supplied titles.
- Commit through a connection context.
- Close and reopen the connection.
- Check stored titles in a deliberate order.
- Explain why the statement structure is unchanged.
Hint: where to begin
The parameter tuple supplies values separately from SQL; it does not paste them into the command.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Apostrophe title | Stored without SQL syntax failure |
| SQL-looking title | Stored as one value; table remains |
| Reopened connection | Both committed rows present |
Reveal one possible solution and explanation
import sqlite3
from pathlib import Path
from tempfile import TemporaryDirectory
with TemporaryDirectory() as folder:
path = Path(folder) / "practice.sqlite3"
with sqlite3.connect(path) as db:
db.execute("CREATE TABLE tasks (id INTEGER PRIMARY KEY, title TEXT NOT NULL)")
db.executemany("INSERT INTO tasks(title) VALUES (?)", [("Maya's planner",), ("x'); DROP TABLE tasks; --",)])
db.close()
reopened = sqlite3.connect(path)
rows = reopened.execute("SELECT title FROM tasks ORDER BY id").fetchall()
assert len(rows) == 2
assert rows[0][0] == "Maya's planner"
assert "DROP TABLE" in rows[1][0]
reopened.close()
print("Committed values reopened as data")The connection context commits successful writes, but it does not close the connection automatically. This example closes each explicitly. The temporary directory is deleted after the exercise.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Trigger a constraint failure inside a new transaction and verify its uncommitted additions roll back.
07 | AUTHENTICATION
VERIFY A PASSWORD WITHOUT STORING IT AS TEXT
Create and verify a salted scrypt password hash using only a fictional practice value. Explain what the helper does and what it does not do.
YOUR REQUIREMENTS
- Use Werkzeug password helpers.
- Never print the password or hash.
- Check correct and incorrect candidates.
- Create a second hash for the same value.
- Explain why the stored representations differ.
- Describe one separate abuse-control requirement.
Hint: where to begin
The random salt changes the representation. Verification uses the helper, not equality against a newly generated hash.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Correct fictional candidate | True verification |
| Incorrect candidate | False verification |
| Two hashes of same value | Different salted representations |
Reveal one possible solution and explanation
from werkzeug.security import generate_password_hash, check_password_hash
fictional = "Fictional practice phrase 42"
first = generate_password_hash(fictional, method="scrypt")
second = generate_password_hash(fictional, method="scrypt")
assert first != second
assert check_password_hash(first, fictional)
assert not check_password_hash(first, "wrong")
assert first != fictional
print("Salted hash verification checked")The app can verify a candidate without recovering the original password. This exercise does not add login rate limits, password recovery or broader session revocation.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Explain how the portal CLI prevents a browser visitor from selecting a teacher role.
08 | AUTHORISATION
ENFORCE A RECORD POLICY IN ALL CASES
Express the portal read policy as a pure function, test each actor and connect it to direct request tests in the downloaded portal.
YOUR REQUIREMENTS
- An inactive or missing actor is denied.
- An owner can read their own record.
- A teacher can read a shared record.
- A teacher cannot read a peer private draft.
- A different student cannot read even shared work.
- Explain why this pure function is not sign-in.
Hint: where to begin
Accept established actor data for the model. In a real route, load it on the server rather than from posted role fields.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Owner with own draft | Allowed |
| Peer with shared record | Denied |
| Teacher with draft/shared | Denied/allowed respectively |
| Missing or inactive actor | Denied |
Reveal one possible solution and explanation
def may_read(user, project):
if user is None or not user["active"]:
return False
return user["id"] == project["owner_id"] or (user["role"] == "teacher" and project["status"] == "shared")
owner = {"id": 1, "role": "student", "active": True}
peer = {"id": 2, "role": "student", "active": True}
teacher = {"id": 3, "role": "teacher", "active": True}
draft = {"owner_id": 1, "status": "draft"}
shared = {**draft, "status": "shared"}
assert may_read(owner, draft)
assert not may_read(peer, shared)
assert not may_read(teacher, draft)
assert may_read(teacher, shared)
assert not may_read(None, shared)
assert not may_read({**owner, "active": False}, draft)
print("Six read-policy cases checked")The function models authorisation after identity is established. It does not validate passwords or signed cookies. Run the portal’s direct-route tests to check enforcement around real stored records.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Write an edit policy permitting only the student owner and test a teacher with shared work.
09 | TESTING
KEEP SECURITY CHECKS IN REQUEST TESTS
Check that a write without the lab’s CSRF token fails even in testing mode. Compare that with a valid accepted write.
YOUR REQUIREMENTS
- Use a temporary configured app.
- Leave CSRF verification enabled.
- Send a missing token and a wrong token.
- Check response status and unchanged row count.
- Then obtain the real token and accept a valid write.
Hint: where to begin
TESTING changes error reporting behaviour; it is not permission to remove the boundary you want to test.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| No or wrong token | 400, zero tasks |
| Current token | 303, one task |
| Earlier rejected attempts | No partial row |
Reveal one possible solution and explanation
from pathlib import Path
from tempfile import TemporaryDirectory
from app import create_app
with TemporaryDirectory() as folder:
app = create_app({"TESTING": True, "SECRET_KEY": "test-only-" + "x" * 64, "DATABASE": str(Path(folder) / "tasks.sqlite3")})
client = app.test_client()
fields = {"title": "Debug a route", "minutes": "20"}
assert client.post("/tasks/new", data=fields).status_code == 400
assert client.post("/tasks/new", data={**fields, "csrf": "wrong"}).status_code == 400
assert client.get("/api/summary").json["task_count"] == 0
client.get("/tasks/new")
with client.session_transaction() as session:
token = session["csrf"]
assert client.post("/tasks/new", data={**fields, "csrf": token}).status_code == 303
assert client.get("/api/summary").json["task_count"] == 1
print("Token rejection and acceptance checked")Run beside app.py in the learning lab. The sample proves a token boundary, not user ownership, because this first lab deliberately has no accounts.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Repeat the same missing-token expectation for the portal’s sign-in and protected write routes.
10 | DEPLOYMENT
CHECK PRODUCTION CONFIGURATION WITHOUT PUBLISHING
Construct the portal in production mode with a temporary database and fictional secret, then inspect the cookie and response settings.
YOUR REQUIREMENTS
- Run beside the portal package.
- Keep the database temporary.
- Configure production mode in an isolated environment patch.
- Check Secure, HttpOnly and SameSite.
- Send a test HTTPS request.
- Explain what still needs hosting and operational setup.
Hint: where to begin
An HTTPS test URL checks response configuration; it does not create a real certificate or public deployment.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Production configuration | Secure and HttpOnly enabled; SameSite Lax |
| HTTPS test request | Secure cookie flag and HSTS header |
| Public availability | Not established by this test |
Reveal one possible solution and explanation
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from unittest.mock import patch
from portal import create_app
with TemporaryDirectory() as folder:
config = {"PORTAL_MODE": "production", "PORTAL_SECRET": "test-only-" + "x" * 64, "PORTAL_DATABASE": str(Path(folder) / "portal.sqlite3")}
with patch.dict(os.environ, config):
app = create_app()
assert app.config["SESSION_COOKIE_SECURE"]
assert app.config["SESSION_COOKIE_HTTPONLY"]
assert app.config["SESSION_COOKIE_SAMESITE"] == "Lax"
response = app.test_client().get("/login", base_url="https://example.test")
assert response.status_code == 200
assert "Secure" in response.headers["Set-Cookie"]
assert "Strict-Transport-Security" in response.headers
print("Production response configuration checked")A fixed secret is acceptable only for isolated fictional tests. Real deployment must use strong private configuration and supported HTTPS hosting, backups, monitoring and abuse controls.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Write a checklist identifying each configuration owner and an observable test for its real deployment boundary.
11 | PORTAL CAPSTONE
REJECT A PEER WRITE WITHOUT LOSING DATA
Use the capstone’s test fixture to attempt a direct edit as another student. Do not rely on the absence of an edit button.
YOUR REQUIREMENTS
- Run this sample beside test_portal.py.
- Use its temporary fixture.
- Sign in as noor, not the record owner maya.
- Post to the known edit URL with a valid token.
- Expect 403.
- Compare the whole record before and after.
Hint: where to begin
A valid CSRF token does not grant ownership. Both checks must hold.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Peer direct POST with valid token | 403 |
| Stored record comparison | Unchanged |
| Owning student edits valid fields | Accepted in the full suite |
Reveal one possible solution and explanation
from test_portal import PortalTests
PortalTests.setUpClass()
case = PortalTests(methodName="test_public_home_and_protected_redirect")
case.setUp()
try:
before = case.row(1)
assert case.login("noor").status_code == 303
response = case.post("/projects/1/edit", {"title": "Forged edit", "summary": "Not the owner", "language": "Python", "status": "shared"})
assert response.status_code == 403
assert case.row(1) == before
print("Forbidden direct write left the record unchanged")
finally:
case.doCleanups()The sample reuses the supplied fixture for compactness; its temporary directory is cleaned in finally. It tests a real route and stored effect, not merely the pure policy function.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Add a peer delete attempt and verify the record still exists with all its earlier values.
12 | FULL JOURNEY
COMPLETE A TEACHER REVIEW AND REVISION
Test the student-teacher workflow, then hand over a documented local portal to a partner.
YOUR REQUIREMENTS
- Use fictional accounts.
- Verify a teacher cannot see the owner’s draft.
- Share a project as its owner.
- Leave teacher feedback.
- Edit as owner and verify feedback clears.
- Run all 28 portal tests and perform the manual journey.
- Document setup, data location and known public-service gaps.
Hint: where to begin
The shared fixture contains a draft record and a shared one, allowing the two read rules to be tested independently.
TEST YOUR PROGRAM
| Test input or action | Expected result |
|---|---|
| Teacher requests draft | 403 |
| Teacher reviews shared submission | Feedback saved |
| Owner saves revision | Feedback cleared under stated policy |
| Partner handover | Can follow setup and workflow without hidden assumptions |
Reveal one possible solution and explanation
from test_portal import PortalTests
PortalTests.setUpClass()
case = PortalTests(methodName="test_public_home_and_protected_redirect")
case.setUp()
try:
assert case.login("teacher").status_code == 303
assert case.client.get("/projects/1").status_code == 403
assert case.client.get("/projects/2").status_code == 200
assert case.post("/projects/2/feedback", {"feedback": "Explain your test cases."}).status_code == 303
assert case.row(2)["feedback"] == "Explain your test cases."
case.post("/logout")
assert case.login("maya").status_code == 303
assert case.post("/projects/2/edit", {"title": "Revised project", "summary": "Added testing evidence", "language": "Python", "status": "shared"}).status_code == 303
assert case.row(2)["feedback"] == ""
print("Teacher review and owner revision checked")
finally:
case.doCleanups()Earlier feedback refers to an earlier version. This model clears it explicitly rather than silently claiming it applies to new work. It does not preserve feedback history; versioning is an extension.
A different implementation can also be correct if it meets the requirements and tests. Compare meaning and behaviour, rather than only copying the source.
Extension: Design submission-version records and explain how you would keep an old review attached to the correct version.
KEEP A SMALL TEST RECORD
For each request, record actor, method, path, starting data, submitted fields, expected status, expected stored effect and actual result. A denied-write test must check unchanged data as well as the response.
Distinguish pure policy checks, test-client checks, manual browser journeys and actual deployment evidence. Never include passwords, signing secrets or session cookies in your record.
READY FOR THE NEXT SECTION?
You are ready to move on when you can handle routes and responses, render escaped templates, validate and commit accepted data, enforce ownership on direct requests and explain a tested deployment plan.
If one skill is uncertain, revisit its lesson and change a worked example before repeating the challenge. The Section 9 workbook is coming soon.
Next: Django projects, apps and URLs Python course navigation