Concepts 🚀 Project 🧮 Labs
🚀 Learn with Project / Stage 9 — Add Jinja2 templates — the form page
🚀 Stage 9 of 13
← Previous — Stage 8
First FastAPI route — POST /predict
←

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.

🧠 The intuition Right now /predict only accepts JSON. Browsers can't send JSON from a form — they send form-encoded data. Two options: A. Add a SECOND route that accepts form-encoded data and returns HTML B. Use JavaScript fetch() to send JSON from the form Option A is simpler and more classic — the form POSTs to /predict-form, FastAPI reads the form fields, calls the model, and renders result.html with the prediction. No JavaScript. This is the Jinja2 pattern.
✏️ Tiny task — write this yourself Create base.html with a nav and an empty {% block content %}. Then create home.html that extends base.html and renders a <form> with 7 number inputs, one per feature. Don't worry about styling yet.
✅ Check — what you should see Visit http://localhost:8000/ — you should see a bare HTML form with 7 inputs and a Submit button. The form's action='/predict-form' method='POST'. Submitting will 404 until we add the route in the next file.

🧮 Code files for this stage

📄 app/main.py python
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)
📄 app/templates/base.html html
<!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>
📄 app/templates/home.html 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 %}
📄 app/templates/result.html html
{% 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'>&larr; 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.

🚧 Routes — what's wired up at this stage ROUTES INTRODUCED HERE: GET / -> renders home.html (the form) POST /predict-form -> reads form data, calls model, renders result.html Plus the existing POST /predict (JSON API) from Stage 8 stays — we now have TWO ways to get a prediction: JSON (for scripts) and form (for browsers). Total: 3 routes.
🧮 Is a response model necessary here? NO — use response_class=HTMLResponse

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.

⚠️ Common mistakes to avoid
  • 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.
👀 What's next Next: Stage 10 adds CSS — same templates, but now they look like a real product. We'll also add the 'cards' layout from your HTML mockup.
Next — Stage 10
Add CSS — cards layout, hover effects
→