Learn
09.3 | FORMS, VALIDATION AND DATABASES
01 | A FORM IS A REQUEST BUILDER
An HTML form collects controls and asks the browser to submit a request. Its action chooses the destination and its method chooses how to send it. A control’s name identifies the submitted field; id mainly connects labels, styling and scripts to the element.
The learning lab’s form sends title, minutes and a hidden csrf token to /tasks/new. A field without a submitted name cannot be retrieved just because the label looks correct.
A browser is not a trusted source of business rules. Someone can send a request without using your page, so the server must still validate the data even when the HTML has required, min or maxlength.
02 | OPEN THE FORM WITH GET, SUBMIT WITH POST
The route accepts both GET and POST. GET displays the form. POST attempts validation and saving, then either shows correction feedback or redirects to the task list.
request.form contains submitted form fields. A value such as "25" arrives as text, so int converts it before the minute range is checked. Missing fields get deliberate defaults rather than crashing at an unexpected key lookup.
Use the complete learning lab download from 09.2 and follow its README. It uses fictional local data without account controls; 09.4 and the portal add identity and ownership.
HTML TEMPLATE | TEMPLATES/NEW.HTML
{% extends 'base.html' %}{% block content %}<h2>Add a task</h2>{% if error %}<p role="alert">Not saved: {{ error }}</p>{% endif %}<form method="post"><input type="hidden" name="csrf" value="{{ csrf_token() }}"><label for="title">Task title</label><input id="title" name="title" maxlength="80" value="{{ values.get('title','') }}" required><label for="minutes">Planned minutes (1-240)</label><input id="minutes" name="minutes" type="number" min="1" max="240" step="1" value="{{ values.get('minutes','25') }}" required><button type="submit">Save task</button></form>{% endblock %}
03 | A SECRET AND FORM TOKEN FOR THE LAB
The lab stores an unpredictable form token in a signed session and includes it as a hidden field. The submitted token must match before a POST reaches its route. That protection is not supplied automatically by merely writing method="post".
Set PORTAL_SECRET in the process environment before running the factory. The README gives PowerShell and macOS/Linux commands. Generate the value with secrets, keep it out of source history and use the same chosen secret for this local app across restarts.
The signing secret protects session integrity; it is not a password hash or database encryption key. Lesson 09.4 explains sessions and cross-site request forgery more fully. Do not remove the hidden field to make a failing form appear to work.
TERMINAL | GENERATE A VALUE, THEN SET IT AS THE README SHOWS
python -c "import secrets; print(secrets.token_hex(32))" python -m flask --app app:create_app run --debug
04 | CONVERSION AND VALIDATION ANSWER DIFFERENT QUESTIONS
int("25") produces the integer 25. int("twenty-five") raises ValueError. However int("-3") succeeds, so conversion alone does not establish a useful study duration.
The lab requires a trimmed non-blank title up to eighty characters and whole minutes from one to 240. It checks those rules before opening an accepted insert operation.
If validation fails, the route returns the form with an error and preserves the submitted values for correction. The status is 400, and no new task should appear in the database.
PYTHON | VALIDATION EXCERPT FROM THE LAB
title = request.form.get("title", "").strip()
minutes = int(request.form.get("minutes", ""))
if not 1 <= len(title) <= 80:
raise ValueError("Use 1-80 non-blank title characters.")
if not 1 <= minutes <= 240:
raise ValueError("Minutes must be from 1 to 240.")05 | KEEP VALUES SEPARATE FROM SQL
The insert uses ? placeholders and a separate tuple containing title and minutes. A title with an apostrophe or SQL-looking text remains a data value rather than changing the SQL instruction.
Parameters do not replace validation, access control or HTML escaping. They address query construction. The title still needs length rules when saved and escaping when displayed.
A connection is stored in g for the current application context. A teardown callback closes it after the context ends. A Python global connection shared across unrelated requests would create lifetime and concurrency problems.
PYTHON | ACCEPTED WRITE EXCERPT
db = database()
with db:
db.execute("INSERT INTO tasks(title,minutes) VALUES(?,?)", (title, minutes))06 | COMMIT, THEN REDIRECT
The with db block commits a successful pending transaction. If an error escapes the block, pending changes roll back. After accepted storage, the route redirects to the list with status 303.
This is Post/Redirect/Get, or PRG. The browser submits the change, follows a redirect and displays a GET result page. Refreshing that result page retrieves the list instead of normally resubmitting the original form.
PRG is not an exactly-once guarantee. Repeated clicks or network retries can still send multiple POSTs. Operations requiring strict deduplication need an explicit design such as a unique submission identifier.
| Step | Request / response | Meaning |
|---|---|---|
| 1 | GET /tasks/new → 200 | Display form and token |
| 2 | POST valid values → 303 | Commit then redirect |
| 3 | GET /tasks → 200 | Display accepted result |
| Rejected POST | 400 form with error | No accepted write |
07 | REPORT ACCEPTED STATE, NOT A GUESS
The task list reads the database with ORDER BY id, making its order deliberate. The JSON summary calculates task count and planned minutes from stored rows, not from a browser-only counter.
The SQLite file is placed inside the lab’s instance folder beside its source location. It is not in static and is not intended to be downloaded through an asset URL.
CREATE TABLE IF NOT EXISTS creates a missing table, but does not migrate an existing old table to a changed schema. Keep stored-data updates explicit and back up before schema experiments.
08 | TRACE A SUBMISSION DECISION
Choose a title and a minutes value. This fixed model checks the lab’s rules and shows a rejection or the accepted request flow. It makes no database write and does not provide a real session token.
09 | TEST DIRECT REQUESTS AND REJECTION
The lab includes eight test methods covering routes, static assets, valid saves, redirect flow, invalid fields, missing tokens, HTML escaping and SQL-looking input. It uses a temporary database so the real practice file stays untouched.
Testing only the browser’s numeric control misses the server boundary. Send minutes=0, minutes=241 and minutes=text directly with a valid form token, then verify the row count is unchanged.
Also test a title containing <script> as data and inspect the displayed escaped text. Database parameterisation and HTML escaping protect different boundaries and should each be tested.
TERMINAL | INSIDE THE LAB FOLDER
python -m unittest discover -s . -v
10 | EXTEND ONE RULE AT A TIME
Add a subject or completion field only after defining allowed values and expected reports. A new database column needs a deliberate migration for existing records, not just a changed CREATE statement.
This lab shows storage flow; it does not prove that submitted tasks belong to a signed-in user. In the portal, owner_id comes from the server’s loaded user, and every read or write applies its access rule.
Try it: change a valid title to include an apostrophe, submit, refresh the result, then reopen the process and confirm the accepted row remains. Record where validation, storage and rendering each happen.
PUT YOUR SKILLS TO WORK
Use the Section 9 challenges to practise these ideas independently. Work in your Python editor, predict results and keep a short test record.
Section 9 challenges Python course navigation