Skip to content

Sharing page layout: template inheritance

About 25 minutes

In chapter 5 you built an HTML dashboard. Here, you'll add another page that shows a list of studies. Both the dashboard and third new page, use the same header, footer and styling. Rather than copying that shared code into every page, we use template inheritance: the shared code lives once, in base.html, and each page builds on it.

A studies list

The new page needs a view. Add it to study/views.py:

def study_list(request):
    """Public landing page: every study on the platform.

    This page is public (participants land here), so it shows no dataset counts and no
    links to the staff-only dashboard.
    """
    studies = Study.objects.all()
    return render(request, "study/study_list.html", {"studies": studies})

study/views.py on GitHub

Give it the site's home address by adding this line at the top of the urlpatterns list in study/urls.py:

    path("", views.study_list, name="list"),

study/urls.py on GitHub

Don't open the page yet: the view renders a template, study_list.html, that doesn't exist yet, so you'd get a TemplateDoesNotExist error. That template builds on the shared layout, base.html, so we'll write base.html first, then the list's template.

The base template

base.html stores everything the pages have in common, {% block %} marking the gaps each page can fill (if desired). Create study/templates/study/base.html. Most of it is the shared styling:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{% block title %}Study platform{% endblock title %}</title>
  <style>
    body { font-family: system-ui, sans-serif; max-width: 860px; margin: 0 auto; padding: 0 1rem 2rem; color: #222; }
    a { color: #2a6; }
    header.site { border-bottom: 1px solid #eee; padding: 1rem 0; margin-bottom: 2rem; }
    header.site a { text-decoration: none; font-weight: 600; font-size: 1.05rem; }
    footer.site { border-top: 1px solid #eee; margin-top: 3rem; padding-top: 1rem; color: #999; font-size: 0.85rem; }
    h1 { font-weight: 600; }
    table { border-collapse: collapse; width: 100%; }
    th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #eee; }
    th { color: #777; font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
    .stats { display: flex; gap: 2.5rem; margin: 1.5rem 0; }
    .stat .num { font-size: 2rem; font-weight: 700; }
    .stat .label { color: #777; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.04em; }
    .study { padding: 1rem 0; border-bottom: 1px solid #eee; }
    .study .name { font-size: 1.2rem; font-weight: 600; }
    .study .meta { color: #777; font-size: 0.9rem; }
    .links a, .exports a { margin-right: 1rem; }
    .lede { color: #555; }
    form.stacked p { margin: 0 0 1.25rem; }
    form.stacked label { display: block; font-weight: 600; margin-bottom: 0.25rem; }
    form.stacked input[type=text], form.stacked textarea { width: 100%; padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; font: inherit; }
    form.stacked textarea { min-height: 14rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85rem; }
    form.stacked .helptext { display: block; color: #777; font-size: 0.85rem; margin-top: 0.25rem; }
    form.stacked ul.errorlist { list-style: none; margin: 0 0 0.35rem; padding: 0; color: #c0392b; font-size: 0.9rem; }
    button { font: inherit; padding: 0.55rem 1.1rem; border: 0; border-radius: 4px; background: #2a6; color: #fff; cursor: pointer; }
  </style>
</head>
<body>
  {% include "study/menu.html" %}
  {% block content %}{% endblock content %}
  {% include "study/footer.html" %}
</body>
</html>

study/templates/study/base.html on GitHub

There are 2 mechanisms:

  • {% block %} marks a region a child page can override. base.html gives each block a default (the title falls back to "Study platform"), and a child page replaces it
  • {% include %} pulls in a smaller partial. The header and footer live in their own little menu.html and footer.html files, so they're easy to find and reuse

Create study/templates/study/menu.html:

<header class="site"><a href="{% url 'study:list' %}">Study platform</a></header>

study/templates/study/menu.html on GitHub

and study/templates/study/footer.html:

<footer class="site">A study-hosting platform, built with Django.</footer>

study/templates/study/footer.html on GitHub

{% url 'study:list' %} builds the link from the URL's name, so the header on every page points back to the studies list.

A page that extends it

Each page now extends base.html. Create study/templates/study/study_list.html:

{% extends "study/base.html" %}
{% block title %}Studies{% endblock title %}
{% block content %}
  <h1>Studies</h1>
  {% for study in studies %}
    <div class="study">
      <div class="name">{{ study.name }}</div>
      <div class="links">
        <a href="{% url 'study:detail' study.slug %}?preview=1">Try it (nothing saved) →</a>
      </div>
    </div>
  {% empty %}
    <p>No studies are currently available.</p>
  {% endfor %}
{% endblock content %}

study/templates/study/study_list.html on GitHub

{% extends %}needs to be on every page that extends another.

Replace the whole of study/templates/study/dashboard.html with the below. It extends base.html too, shows counts, and provide a link back to the list:

{% extends "study/base.html" %}
{% block title %}{{ study.name }} data{% endblock title %}
{% block content %}
  <p><a href="{% url 'study:list' %}">← all studies</a></p>
  <h1>{{ study.name }}</h1>

  <div class="stats">
    <div class="stat"><div class="num">{{ n_datasets }}</div><div class="label">Datasets</div></div>
    <div class="stat"><div class="num">{{ n_participants }}</div><div class="label">Participants</div></div>
  </div>

  <div class="exports">
    <a href="{% url 'study:export_csv' study.slug %}">Download CSV</a>
    <a href="{% url 'study:export_json' study.slug %}">Download JSON</a>
  </div>

  <table>
    <thead>
      <tr><th>Dataset</th><th>Participant</th><th>Condition</th><th>Collected</th></tr>
    </thead>
    <tbody>
      {% for dataset in datasets %}
        <tr>
          <td>{{ dataset.pk }}</td>
          <td>{{ dataset.participant.external_id|default:"–" }}</td>
          <td>{{ dataset.condition|default:"–" }}</td>
          <td>{{ dataset.created|date:"j M Y, H:i" }}</td>
        </tr>
      {% empty %}
        <tr><td colspan="4">No data collected yet. <a href="{% url 'study:detail' study.slug %}?participant_id=demo">Run the study →</a></td></tr>
      {% endfor %}
    </tbody>
  </table>
{% endblock content %}

study/templates/study/dashboard.html on GitHub

There's a catch: if you added the Trials column in chapter 7's exercise, this will disappear. To keep it, put back the two lines from the exercise's answer, <th>Trials</th> and <td>{{ dataset.data|length }}</td>, and change the empty row's colspan="4" to colspan="5".

The participant's study page, study_detail.html, stays a stand-alone file. jsPsych takes over the whole page, and saveData replaces the page body when the study ends, so a site header and footer would only get in the way.

Checkpoint

Open http://127.0.0.1:8000/ and you'll see your studies:

The Studies page: a "Study platform" header, the Stroop and Flanker tasks each with a "Try it (nothing saved)" link, and a footer
The studies list. This one has a Stroop task too; yours lists whatever studies you've added.

Then open /study/flanker/dashboard/. It now has the same header and footer, and looks like the screenshot at the top of chapter 5.