What you will build
Turn the repair-desk business layer into a usable browser application. A volunteer enters a ticket title, sees saved tickets, and resolves one with a button. Refreshing the browser must preserve records because SQLite, rather than browser memory, holds the application data.
Complete the backend project first and keep its tickets.py and test_tickets.py in your repair-desk directory. You need Python 3.9 or newer and a modern browser; no frontend package manager is required. This lesson supplies a complete transport adapter and a single HTML page. Review backend APIs if status codes are unfamiliar. The API playground is useful for reasoning about requests before involving the browser.
The local server is an exercise. It has no identity system and must contain only synthetic data. The official Python documentation warns that http.server is not recommended for production. Making a page work locally is one milestone; choosing a supported public serving stack is separate work.
Requirements and request flow
Keep the existing model: integer id, validated title, and open or resolved status. Do not add duplicate state in localStorage. The list response is a JSON array, and create/resolve responses are JSON ticket objects. Expected failures use an error string. The client must inspect HTTP status, show errors, and render titles as text.
Use one origin for both HTML and API: http://127.0.0.1:8000. A relative fetch URL reaches the server that delivered the page. Opening the HTML directly from disk does not reproduce this setup. Serving a frontend on a different port creates a different origin and requires a considered cross-origin policy; it is not fixed by adding arbitrary wildcard headers.
Milestone 1: expose the business layer
Save the following as app.py beside tickets.py. Only explicitly named routes can return files, so database files and source code are not accidentally published. Each request uses the business module's short-lived connections. The socket timeout and body cap reduce obvious local hangs; they are not a full denial-of-service defense.
import json
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit
from tickets import TicketStore
ROOT = Path(__file__).resolve().parent
class Handler(BaseHTTPRequestHandler):
def setup(self):
super().setup()
self.connection.settimeout(5)
def reply(self, status, value):
body = json.dumps(value).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path = urlsplit(self.path).path
if path == "/":
body = (ROOT / "index.html").read_bytes()
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
elif path == "/api/tickets":
try:
self.reply(200, self.server.store.list())
except sqlite3.Error:
self.log_error("ticket storage unavailable")
self.reply(503, {"error": "storage unavailable"})
else:
self.reply(404, {"error": "route not found"})
def do_POST(self):
path = urlsplit(self.path).path
self.close_connection = True
try:
if path == "/api/tickets":
media = self.headers.get("Content-Type", "").split(";")[0]
if media.strip().lower() != "application/json":
self.reply(415, {"error": "use application/json"})
return
size = int(self.headers.get("Content-Length", "0"))
if not 0 < size <= 4096:
self.reply(413, {"error": "body must be 1 to 4096 bytes"})
return
data = json.loads(self.rfile.read(size).decode("utf-8"))
if not isinstance(data, dict) or set(data) != {"title"}:
raise ValueError("send an object containing only title")
self.reply(201, self.server.store.create(data["title"]))
elif path.startswith("/api/tickets/") and path.endswith("/resolve"):
parts = path.split("/")
if len(parts) != 5:
self.reply(404, {"error": "route not found"})
return
self.reply(200, self.server.store.resolve(int(parts[3])))
else:
self.reply(404, {"error": "route not found"})
except (ValueError, UnicodeDecodeError):
self.reply(400, {"error": "invalid request or ticket data"})
except LookupError:
self.reply(404, {"error": "ticket not found"})
except sqlite3.Error:
self.log_error("ticket storage unavailable")
self.reply(503, {"error": "storage unavailable"})
def make_server(port=8000, db_path=None):
server = ThreadingHTTPServer(("127.0.0.1", port), Handler)
server.store = TicketStore(db_path or ROOT / "tickets.db")
return server
if __name__ == "__main__":
with make_server() as server:
print("Open http://127.0.0.1:8000")
server.serve_forever()
The handler translates transport failures without putting SQL exceptions into responses. Unsupported HTTP methods still use the base server's default behavior; this miniature adapter is not a complete API framework. Resolution accepts no meaningful request body. In a production implementation, specify method handling, identifier syntax, body framing, and error envelopes consistently across every endpoint.
Milestone 2: build the browser interface
Save this complete page as index.html. Its relative URLs match the adapter above. The form provides a label, and the status region announces results without making users guess whether a request finished. The button is disabled during a write to discourage duplicate submissions.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Repair desk</title>
<h1>Repair desk</h1>
<form id="form">
<label for="title">Item needing repair</label>
<input id="title" name="title" required maxlength="120">
<button id="create">Create ticket</button>
</form>
<p id="message" role="status"></p>
<button id="refresh" type="button">Refresh tickets</button>
<ul id="tickets"></ul>
<script>
const form = document.querySelector('#form');
const title = document.querySelector('#title');
const create = document.querySelector('#create');
const message = document.querySelector('#message');
const list = document.querySelector('#tickets');
async function request(path, options = {}) {
const response = await fetch(path, options);
const data = await response.json();
if (!response.ok) throw new Error(data.error || 'Request failed');
return data;
}
async function refresh() {
const tickets = await request('/api/tickets');
list.replaceChildren();
for (const ticket of tickets) {
const row = document.createElement('li');
const label = document.createElement('span');
label.textContent = `${ticket.title} (${ticket.status}) `;
row.append(label);
if (ticket.status === 'open') {
const button = document.createElement('button');
button.textContent = 'Resolve';
button.addEventListener('click', async () => {
button.disabled = true;
try {
await request(`/api/tickets/${ticket.id}/resolve`, {method: 'POST'});
await refresh();
message.textContent = 'Ticket resolved.';
} catch (error) {
message.textContent = error.message;
button.disabled = false;
}
});
row.append(button);
}
list.append(row);
}
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
create.disabled = true;
try {
await request('/api/tickets', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({title: title.value})
});
form.reset();
message.textContent = 'Ticket saved; refreshing list.';
await refresh();
message.textContent = 'Ticket saved.';
} catch (error) {
message.textContent = `Check the list before retrying: ${error.message}`;
} finally {
create.disabled = false;
}
});
document.querySelector('#refresh').addEventListener('click', () => {
refresh().catch(error => { message.textContent = error.message; });
});
refresh().catch(error => { message.textContent = error.message; });
</script>
</html>
python3 -m unittest -v
python3 app.py
Leave that terminal running, then open the printed URL. Create a lamp ticket, resolve it, and refresh the page. Stop the server with Control-C, restart it, and verify the ticket remains. If port 8000 is busy, stop the other local service or change the make_server default and browser URL together.
Milestone 3: test across the HTTP boundary
In another terminal, run these requests. Expect 201, 400, and 404 respectively; curl's -i displays the response status and headers. These commands add synthetic data to your local database.
curl -i http://127.0.0.1:8000/api/tickets \
-H 'Content-Type: application/json' -d '{"title":"Repair kettle"}'
curl -i http://127.0.0.1:8000/api/tickets \
-H 'Content-Type: application/json' -d '{"title":" "}'
curl -i -X POST http://127.0.0.1:8000/api/tickets/999999/resolve
A browser check complements the business tests. Use developer tools to inspect the POST payload, returned identifier, and subsequent GET. Submit a title containing an HTML-looking tag: it must appear literally, because textContent creates text rather than executable markup. Keyboard users must reach every control and hear or see the status message.
Then stop the server while the page remains open. Refresh tickets and observe an error instead of an unexplained empty list. Restart and recover using the refresh button. Review testing to distinguish isolated business tests, HTTP integration tests, and user-flow checks. Automated business tests alone cannot catch a misspelled fetch route.
Failure and security cases
A successful create followed by a failed refresh is not a failed create. The interface tells users to inspect the list before retrying because another submission might duplicate a committed record. Disabling a button improves interaction; it does not provide server-side idempotency. Multiple tabs can still write concurrently, and overlapping refreshes can show stale snapshots. A later iteration can use request sequencing or cancellation for list requests.
Input length in HTML is convenience validation. A caller can bypass the page, so the Python module remains responsible for the rule. Safe text rendering reduces this particular injection risk; it does not replace a broader browser security policy. Never embed database credentials or service secrets in JavaScript.
Loopback binding, no CORS headers, and synthetic data define the exercise boundary. They do not form an authentication system or prevent every local browser attack. Before public use, require verified identity, scope records to owners, choose CSRF defenses for cookie-based sessions, restrict origins, and enforce request limits at a supported server or gateway. See authentication.
Deployment design and review
Keep local delivery as the runnable target. A future Render service must bind to 0.0.0.0 and the provider's PORT, as described in web service setup; this adapter intentionally binds only to loopback. Replace it with a production serving stack before making that change. Persist SQLite under a disk mount, account for single-instance limits, and rehearse backup restoration.
GitHub Pages can host static HTML but cannot run this Python process. Splitting the frontend there requires an HTTPS backend URL and explicit CORS design. Relative /api/tickets requests would otherwise reach the Pages origin. Consult deployment before selecting that topology.
Review on ten points: three for working create/list/resolve, two for persistence and observable network requests, two for error recovery, two for safe rendering and keyboard access, and one for a credible hosting plan. Discuss where data lives, why fetch needs a status check, how duplicate creation occurs after a timeout, and how cookie authentication changes the threat model. Explain those tradeoffs using an actual request trace, rather than claiming the demo is production-ready.
Continue on this track
Start with the backend project for the complete business module and tests. After connecting the browser, use the team workflow project to deliver a reviewed change.