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

Check predictions against actual results
Test input or actionExpected result
GET /200, HTML home heading
GET /projects/1200, intended project
GET /projects/99404
POST /projects/1405
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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
HTML reportList with escaped angle brackets
JSON endpointcount is 2 and titles are structured data
Unknown route404
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"&lt;Study planner&gt;" 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Two supplied tasksBoth titles and durations displayed
Empty collectionNo tasks message
Title with markupEscaped 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Markup-like titleEscaped output, no script element
Ordinary titleSame readable title
Lab /static/lab.css200 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 "&lt;script&gt;" 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
25 minutes303, one stored task
0 and 241400, earlier row preserved
text400, 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Apostrophe titleStored without SQL syntax failure
SQL-looking titleStored as one value; table remains
Reopened connectionBoth 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Correct fictional candidateTrue verification
Incorrect candidateFalse verification
Two hashes of same valueDifferent 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Owner with own draftAllowed
Peer with shared recordDenied
Teacher with draft/sharedDenied/allowed respectively
Missing or inactive actorDenied
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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
No or wrong token400, zero tasks
Current token303, one task
Earlier rejected attemptsNo 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Production configurationSecure and HttpOnly enabled; SameSite Lax
HTTPS test requestSecure cookie flag and HSTS header
Public availabilityNot 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Peer direct POST with valid token403
Stored record comparisonUnchanged
Owning student edits valid fieldsAccepted 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.

Revisit the related lesson

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

Check predictions against actual results
Test input or actionExpected result
Teacher requests draft403
Teacher reviews shared submissionFeedback saved
Owner saves revisionFeedback cleared under stated policy
Partner handoverCan 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.

Revisit the related lesson

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