Forms

Goals

Deleting a Record

What is the difference between a GET request and a POST request?

Modify server_db.py to create server_delete.py. Add a Delete button to the sighting detail page that sends a POST request to remove the sighting and then sends the browser back to the home page.

i
    @get("/sighting/{sighting_id:int}", media_type=MediaType.HTML)
    async def detail(sighting_id: int) -> str:
        conn = sqlite3.connect(db_path)
        conn.row_factory = sqlite3.Row
        row = conn.execute(
            "select * from sightings where id = ?", [sighting_id]
        ).fetchone()
        conn.close()
        if row is None:
            raise NotFoundException(f"No sighting with ID {sighting_id}")
        return str(
            html(lang="en")[
                head[
                    title[f"Sighting {sighting_id}"],
                    link(rel="stylesheet", href="/style.css"),
                ],
                body[
                    table[
                        [
                            tr[
                                td(class_="label")[lbl],
                                td[fmt(row[key])],
                            ]
                            for key, lbl in LABELS.items()
                        ],
                    ],
                    a(href="/")["Back to all sightings"],
                    form(method="post", action=f"/delete/{sighting_id}")[
                        button(type="submit")["Delete this sighting"],
                    ],
                ],
            ]
        )
i
    @post("/delete/{sighting_id:int}")
    async def delete_sighting(sighting_id: int) -> Redirect:
        conn = sqlite3.connect(db_path)
        conn.execute("delete from sightings where id = ?", [sighting_id])
        conn.commit()
        conn.close()
        return Redirect("/", status_code=303)

Adding a Record

Add a link to the home page and a GET /add route that displays a form for adding a new sighting. Save the result as server_add.py.

i
@get("/add", media_type=MediaType.HTML)
async def add_form() -> str:
    return str(
        html(lang="en")[
            head[
                title["Add a Sighting"],
                link(rel="stylesheet", href="/style.css"),
            ],
            body[
                h1["Add a New Sighting"],
                form(method="post", action="/add")[
                    label[
                        "Species", inp(type="text", name="species", required=True)
                    ],
                    label["Sex (optional)", inp(type="text", name="sex")],
                    label[
                        "Weight in kg (optional)",
                        inp(type="number", name="weight", step="0.1"),
                    ],
                    label["Color", inp(type="text", name="color", required=True)],
                    label[
                        "Date and time (YYYY-MM-DD HH:MM)",
                        inp(type="text", name="datetime", required=True),
                    ],
                    label[
                        "Latitude",
                        inp(
                            type="number",
                            name="latitude",
                            step="0.01",
                            required=True,
                        ),
                    ],
                    label[
                        "Longitude",
                        inp(
                            type="number",
                            name="longitude",
                            step="0.01",
                            required=True,
                        ),
                    ],
                    button(type="submit")["Add Sighting"],
                ],
                a(href="/")["Back to all sightings"],
            ],
        ]
    )

Add a POST /add route to server_add.py that reads the submitted form data, inserts a new row, and redirects the user to the home page.

i
@post("/add")
async def add_sighting(
    data: Annotated[dict, Body(media_type=RequestEncodingType.URL_ENCODED)],
    db_path: Path,
) -> Redirect:
    conn = sqlite3.connect(db_path)
    conn.execute(
        INSERT_ROW,
        [
            data["species"],
            data["sex"] or None,
            float(data["weight"]) if data["weight"] else None,
            data["color"],
            data["datetime"],
            float(data["latitude"]),
            float(data["longitude"]),
        ],
    )
    conn.commit()
    conn.close()
    return Redirect("/", status_code=303)
i
def make_app(db_path: Path = DB_PATH) -> Litestar:
    return Litestar(
        [index, detail, delete_sighting, add_form, add_sighting, styles],
        dependencies={"db_path": Provide(lambda: db_path, sync_to_thread=False)},
    )

Uploading Many Records at Once

What does a form look like when it needs to send a file instead of typed text, and how does the server receive and parse the contents?

Add GET /upload and POST /upload routes to server_add.py to create server_upload.py. The GET route should show a file upload form; the POST route should read the CSV and insert all its rows into the database.

i
@dataclass
class CsvUpload:
    csv_file: UploadFile
i
    @get("/upload", media_type=MediaType.HTML)
    async def upload_form() -> str:
        return str(
            html(lang="en")[
                head[
                    title["Upload Sightings"],
                    link(rel="stylesheet", href="/style.css"),
                ],
                body[
                    h1["Upload Sightings from a CSV File"],
                    form(
                        method="post", action="/upload", enctype="multipart/form-data"
                    )[
                        label[
                            "CSV file", inp(type="file", name="csv_file", accept=".csv")
                        ],
                        button(type="submit")["Upload"],
                    ],
                    a(href="/")["Back to all sightings"],
                ],
            ]
        )
i
    @post("/upload")
    async def upload_csv(
        data: Annotated[CsvUpload, Body(media_type=RequestEncodingType.MULTI_PART)],
    ) -> Redirect:
        content = await data.csv_file.read()
        reader = csv.DictReader(io.StringIO(content.decode("utf-8")))
        conn = sqlite3.connect(db_path)
        for row in reader:
            conn.execute(
                INSERT_ROW,
                [
                    row["species"],
                    row["sex"] or None,
                    float(row["weight"]) if row["weight"] else None,
                    row["color"],
                    row["datetime"],
                    float(row["latitude"]),
                    float(row["longitude"]),
                ],
            )
        conn.commit()
        conn.close()
        return Redirect("/", status_code=303)
i
    return Litestar(
        [
            index,
            detail,
            delete_sighting,
            add_form,
            add_sighting,
            upload_form,
            upload_csv,
            styles,
        ]
    )


app = make_app()

A sample CSV for testing the upload:

i
species,sex,weight,color,datetime,latitude,longitude
G. canadensis,Male,180,brown,2024-07-15 08:00,53.20,-117.40
G. horribilus,,,black,2024-07-16 14:00,54.10,-116.90

Start the server:

i
litestar run --app server_upload:app

Testing Routes That Change Data

How do you test a route that deletes a row from the database?

Write tests for the add route and the CSV upload route in test_server_forms.py.

i
def count_rows(db_path):
    conn = sqlite3.connect(db_path)
    n = conn.execute("select count(*) from sightings").fetchone()[0]
    conn.close()
    return n
i
def test_delete_removes_row(small_db):
    with TestClient(app=make_app(small_db)) as client:
        client.post("/delete/1")
    assert count_rows(small_db) == 1


def test_delete_unknown_id_is_harmless(small_db):
    with TestClient(app=make_app(small_db)) as client:
        client.post("/delete/9999")
    assert count_rows(small_db) == 2


def test_add_inserts_row(small_db):
    new_sighting = {
        "species": "G. canadensis",
        "sex": "",
        "weight": "",
        "color": "grey",
        "datetime": "2024-06-01 09:00",
        "latitude": "52.10",
        "longitude": "-118.50",
    }
    with TestClient(app=make_app(small_db)) as client:
        client.post("/add", data=new_sighting)
    assert count_rows(small_db) == 3


def test_upload_csv_inserts_rows(small_db):
    csv_content = (
        "species,sex,weight,color,datetime,latitude,longitude\n"
        "G. canadensis,Male,180,brown,2024-07-15 08:00,53.20,-117.40\n"
        "G. horribilus,,,black,2024-07-16 14:00,54.10,-116.90\n"
    )
    with TestClient(app=make_app(small_db)) as client:
        client.post(
            "/upload",
            files={"csv_file": ("upload.csv", csv_content.encode(), "text/csv")},
        )
    assert count_rows(small_db) == 4

Run the tests from the forms/ directory:

i
pytest test_server_forms.py -v

See [mdn-forms2025] for the HTML forms reference and [python-csv2025] for the Python csv module documentation.

Check Understanding

A classmate adds a Delete link rather than a button: <a href="/delete/5">Delete</a>. What goes wrong, and how do you fix it?

A link sends a GET request, not a POST request. The server should never delete data in response to a GET, because search engines, browsers, and prefetch tools send GET requests automatically. The fix is to wrap a <button type="submit"> inside <form method="post" action="/delete/5">.

The weight field is left blank when a user submits the add form. The handler stores data["weight"] directly in the database. What does the database contain, and why is that a problem?

An empty form field sends the empty string "" to the server, so the database stores "". An empty string is not the same as SQL null: it is a string with no characters, not a missing value. The fix is float(data["weight"]) if data["weight"] else None, which converts an empty string to None. Python's sqlite3 module stores None as SQL null.

The CSV file uses the header Species (capital S) but the handler reads row["species"] (lowercase s). What happens?

Python's dictionary lookup is case-sensitive, so row["species"] raises a KeyError. The header in the CSV must exactly match the key the handler uses. The simplest fix is to use the exact header from the CSV; a more robust fix converts all header names to lowercase when reading the file.

Moving conn.commit() inside the loop so it runs after every insert changes the behavior. When does this matter, and when does it not?

Either way, all rows that were inserted successfully end up in the database. The difference is what happens if the server crashes mid-upload. Committing once at the end means no rows are saved if the crash happens before the commit: the database is unchanged. Committing after every insert means every row committed before the crash stays, leaving partial data in the database. For a small CSV in a tutorial, the difference is unnoticeable.

Exercises

Add a Confirmation Step

Modify the delete route so that clicking "Delete this sighting" first shows a page asking "Are you sure?" with Confirm and Cancel buttons. Only delete the row if the user clicks Confirm.

Validate the Input Before Inserting

The add handler trusts that latitude is between -90 and 90 and longitude is between -180 and 180. Add a check in the POST /add handler that returns an error message if either value is out of range, without inserting any row.

Report Upload Errors

If a row in the CSV is missing a required field, the handler raises an unhandled error. Modify it to skip bad rows and return a page that lists which row numbers were skipped and why.

Edit an Existing Record

Add a GET /edit/{id} route that shows a form pre-filled with the current values of a sighting, and a POST /edit/{id} route that updates the row in the database with the submitted values.