Add Jinja2 templates — the form page
base.html shell + home.html form. POSTs to the same /predict endpoint — but now from a browser, not curl.
🧮 Code files for this stage
from fastapi import FastAPI, Request, Form
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field
import joblib, pandas as pd, uvicorn
app = FastAPI(title='Student Performance Predictor')
templates = Jinja2Templates(directory='app/templates')
model = joblib.load('app/ml/model.pkl')
FEATURES = ['study_hours', 'previous_score', 'attendance',
'assignments_completed', 'sleep_hours',
'extracurricular_hours', 'parental_support']
def _predict(features_dict: dict) -> tuple[float, str]:
X_new = pd.DataFrame([features_dict])[FEATURES]
score = float(model.predict(X_new)[0])
if score < 50: band = 'Poor'
elif score < 70: band = 'OK'
elif score < 85: band = 'Good'
else: band = 'Excellent'
return round(score, 1), band
# ── Route 1: JSON API (Stage 8) ─────────────────────────
class StudentInput(BaseModel):
study_hours: float = Field(..., ge=0, le=24)
previous_score: float = Field(..., ge=0, le=100)
attendance: float = Field(..., ge=0, le=100)
assignments_completed: int = Field(..., ge=0, le=20)
sleep_hours: float = Field(..., ge=0, le=24)
extracurricular_hours: float = Field(..., ge=0, le=10)
parental_support: int = Field(..., ge=1, le=5)
class PredictResponse(BaseModel):
predicted_score: float
performance_band: str
@app.post('/predict', response_model=PredictResponse)
async def predict_json(student: StudentInput):
score, band = _predict(student.model_dump())
return PredictResponse(predicted_score=score, performance_band=band)
# ── Route 2: home page (the form) ───────────────────────
@app.get('/', response_class=HTMLResponse)
async def home(request: Request):
return templates.TemplateResponse('home.html', {
'request': request,
'features': FEATURES,
})
# ── Route 3: form submission ────────────────────────────
@app.post('/predict-form', response_class=HTMLResponse)
async def predict_form(
request: Request,
study_hours: float = Form(...),
previous_score: float = Form(...),
attendance: float = Form(...),
assignments_completed: int = Form(...),
sleep_hours: float = Form(...),
extracurricular_hours: float = Form(...),
parental_support: int = Form(...),
):
features = {
'study_hours': study_hours,
'previous_score': previous_score,
'attendance': attendance,
'assignments_completed': assignments_completed,
'sleep_hours': sleep_hours,
'extracurricular_hours': extracurricular_hours,
'parental_support': parental_support,
}
score, band = _predict(features)
return templates.TemplateResponse('result.html', {
'request': request,
'score': score,
'band': band,
'inputs': features,
})
if __name__ == '__main__':
uvicorn.run(app, host='0.0.0.0', port=8000)
<!DOCTYPE html>
<html>
<head>
<title>{% block title %}Student Predictor{% endblock %}</title>
<link rel='stylesheet' href='/static/style.css'>
</head>
<body>
<header>
<p>Student Performance Predictor</p>
<nav>
<a href='/'>Home</a>
<a href='/docs'>API</a>
</nav>
</header>
<main>
{% block content %}{% endblock %}
</main>
<footer><p>Built with FastAPI + Jinja2 + sklearn</p></footer>
</body>
</html>
{% extends 'base.html' %}
{% block title %}Enter your study habits{% endblock %}
{% block content %}
<h1>Predict your final score</h1>
<form method='post' action='/predict-form'>
<label>Study hours/day
<input type='number' step='0.1' name='study_hours' required>
</label>
<label>Previous score
<input type='number' name='previous_score' required>
</label>
<label>Attendance %
<input type='number' name='attendance' required>
</label>
<label>Assignments completed
<input type='number' name='assignments_completed' required>
</label>
<label>Sleep hours
<input type='number' step='0.1' name='sleep_hours' required>
</label>
<label>Extracurricular hours
<input type='number' step='0.1' name='extracurricular_hours' required>
</label>
<label>Parental support (1-5)
<input type='number' min='1' max='5' name='parental_support' required>
</label>
<button type='submit'>Predict Score</button>
</form>
{% endblock %}
{% extends 'base.html' %}
{% block title %}Your predicted score{% endblock %}
{% block content %}
<div class='result-card'>
<h1>Predicted Score</h1>
<div class='score-number'>{{ score }}</div>
<div class='band'>Performance: {{ band }}</div>
</div>
<h3>You entered:</h3>
<ul>
{% for k, v in inputs.items() %}
<li>{{ k }}: {{ v }}</li>
{% endfor %}
</ul>
<a href='/' class='back-btn'>← Try another student</a>
{% endblock %}
How it works
Three new things: (1) Form(...) — FastAPI's way of saying 'this field comes from form data, not JSON'. The field name MUST match the input name='study_hours' in HTML. (2) TemplateResponse — renders the Jinja template with the variables you pass. Always pass 'request': request (FastAPI requirement). (3) {% extends %} + {% block %} — base.html defines a layout with holes; home.html and result.html fill those holes. Change the nav once in base.html, both pages update. That's the whole point of templates.
The form routes return HTML, not JSON. Pydantic response_model is only for JSON responses — for HTML you use `response_class=HTMLResponse`. We keep the response_model on POST /predict (the JSON API) because that's still useful for scripts and the /docs page.
- Forgetting enctype — for plain text inputs, default enctype works. For file uploads, you need enctype='multipart/form-data'.
- Mismatched Form field name and HTML input name — Form(study_hours) MUST match name='study_hours' exactly.
- Not passing 'request': request to TemplateResponse — Jinja2Templates requires it since FastAPI 0.85.