We'll build a simple quiz application using Django and HTMX in 8 minutes. HTMX is great for creating dynamic web applications without writing JavaScript.
This includes:
- building a multi-stage form using HTMX and Django.
- adding data into your Django database from yaml using the Django
loaddata
management command. - generating data for your quiz app to learn whatever topic you want using an LLM
Here's how our final product will look:
Edit: Thanks to Alex Goulielmos (from Youtube) for carefully reading this guide and correcting an error. Now fixed 👍
For a demo of the app, run the Replit here: Demo
I've made an optional video guide (featuring me 🏇🏿) here that follows the steps in this guide:
Let's get started 🐎
Setup our Django app
- Install packages and create our Django app
pip install --upgrade django pyyaml
django-admin startproject core .
python manage.py startapp sim
- Add our app
sim
to theINSTALLED_APPS
insettings.py
:
# settings.py
INSTALLED_APPS = [
'sim',
...
]
Add templates
- Create a folder
templates
in thesim
app - Create a file
start.html
into thetemplates
folder, containing:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Start your quiz </title>
<script src="https://unpkg.com/htmx.org@1.9.10" integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC" crossorigin="anonymous"></script>
<style>
body {
font-family: 'Arial', sans-serif;
background-color: #f0f0f0;
color: #333;
line-height: 1.6;
padding: 20px;
}
#topic-container {
background-color: #fff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin: auto;
width: 50%;
}
#topic-list {
justify-content: center;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
#question-form {
padding: 20px;
}
.option{
border-radius: 10px;
}
.option input[type="radio"] {
display: none; /* Hide the radio button */
}
.option label {
display: block;
padding: 10px 20px;
background-color: #eeeeee;
border-radius: 5px;
margin: 5px 0;
cursor: pointer;
transition: background-color 0.3s;
}
.option label:hover {
background-color: #c9c9c9;
}
.option input[type="radio"]:checked + label {
background-color: #818181;
color: #fff;
}
#heading-text {
text-align: center;
}
.btn {
background-color: #007bff;
color: #fff;
border: none;
padding: 10px 20px;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s ease-out;
display: block;
margin: 20px auto;
}
.btn:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<form id="topic-container" hx-post="{% url 'get-questions' %}/start">
{% csrf_token %}
<h2 id="heading-text">
What would you like to learn about?
</h2>
<div id="topic-list">
<p>Please pick a topic from the below topics</p>
<ol style="list-style-type: none;">
{% for topic in topics %}
<li class="option">
<input type="radio" id="topic-{{ forloop.counter0 }}" value="{{ topic.id }}" name="quiz_id" required>
<label for="topic-{{ forloop.counter0 }}">{{ topic.name }} ({{ topic.questions_count }} questions)</label>
</li>
{% endfor %}
{% if not topics %}
<li>No topics available. Have you added topics into your database?</li>
{% endif %}
</ol>
<button class="btn" type="submit">Start your quiz</button>
</div>
</form>
</body>
</html>
- Create a folder called
partials
in thetemplates
folder - Create a file
answer.html
in thepartials
folder, containing:
<form hx-post="{% url 'get-questions' %}">
{% csrf_token %}
<input type="hidden" name="quiz_id" value="{{ answer.question.quiz_id }}">
<p>The question:</p>
<p>{{ answer.question.text }}</p>
<br>
<div>
Your answer:
<p>{{ submitted_answer.text }}</p>
</div>
<div>
{% if submitted_answer.is_correct %}
<div>
<p>Correct ✅</p>
</div>
{% else %}
<div>
<p>Incorrect ❌</p>
<p>The correct answer is: </p>
<p>{{ answer.text }}</p>
</div>
{% endif %}
</div>
<button class="btn">
Next question
</button>
</form>
- Create a file
finish.html
in thepartials
folder, containing:
<div>
<p>Quiz complete. You scored {{ percent_score }}%</p>
<p>({{ score }} correct / {{ questions_count }} questions)</p>
<a class="btn" href="{% url 'start' %}">
Start another quiz
</a>
</div>
- Create a file
question.html
in thepartials
folder, containing:
<div>
<form id="question-form" hx-post="{% url 'get-answer' %}">
{% csrf_token %}
<h2 id="heading-text">
{{ question.text }}
</h2>
<ol style="list-style-type: none;">
{% for answer in answers %}
<li class="option">
<input type="radio" id="answer-{{ forloop.counter0 }}" value="{{ answer.id }}" name="answer_id" required>
<label for="answer-{{ forloop.counter0 }}">{{ answer.text }}</label>
</li>
{% endfor %}
</ol>
<button class="btn" type="submit" >
Submit your answer
</button>
</form>
</div>
<script>
window.onbeforeunload = function(){
return "Are you sure you want to leave? You will lose your progress.";
};
</script>
Add views
- Copy the below into
sim/views.py
:
from django.shortcuts import render
from django.http import HttpResponse, HttpRequest
from django.db.models import Count
from .models import Quiz, Question, Answer
from django.core.paginator import Paginator
from typing import Optional
def start_quiz_view(request) -> HttpResponse:
topics = Quiz.objects.all().annotate(questions_count=Count('question'))
return render(
request, 'start.html', context={'topics': topics}
)
def get_questions(request, is_start=False) -> HttpResponse:
if is_start:
request = _reset_quiz(request)
question = _get_first_question(request)
else:
question = _get_subsequent_question(request)
if question is None:
return get_finish(request)
answers = Answer.objects.filter(question=question)
request.session['question_id'] = question.id # Update session state with current question id.
return render(request, 'partials/question.html', context={
'question': question, 'answers': answers
})
def _get_first_question(request) -> Question:
quiz_id = request.POST['quiz_id']
return Question.objects.filter(quiz_id=quiz_id).order_by('id').first()
def _get_subsequent_question(request) -> Optional[Question]:
quiz_id = request.POST['quiz_id']
previous_question_id = request.session['question_id']
try:
return Question.objects.filter(
quiz_id=quiz_id, id__gt=previous_question_id
).order_by('id').first()
except Question.DoesNotExist: # I.e., there are no more questions.
return None
def get_answer(request) -> HttpResponse:
submitted_answer_id = request.POST['answer_id']
submitted_answer = Answer.objects.get(id=submitted_answer_id)
if submitted_answer.is_correct:
correct_answer = submitted_answer
request.session['score'] = request.session.get('score', 0) + 1
else:
correct_answer = Answer.objects.get(
question_id=submitted_answer.question_id, is_correct=True
)
return render(
request, 'partials/answer.html', context={
'submitted_answer': submitted_answer,
'answer': correct_answer,
}
)
def get_finish(request) -> HttpResponse:
quiz = Question.objects.get(id=request.session['question_id']).quiz
questions_count = Question.objects.filter(quiz=quiz).count()
score = request.session.get('score', 0)
percent = int(score / questions_count * 100)
request = _reset_quiz(request)
return render(request, 'partials/finish.html', context={
'questions_count': questions_count, 'score': score, 'percent_score': percent
})
def _reset_quiz(request) -> HttpRequest:
"""
We reset the quiz state to allow the user to start another quiz.
"""
if 'question_id' in request.session:
del request.session['question_id']
if 'score' in request.session:
del request.session['score']
return request
Urls
- Update core.urls with the below:
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('sim.urls')),
]
- Create a file 'urls.py' in
sim
, containing:
from django.urls import path
from . import views
urlpatterns = [
path('', views.start_quiz_view, name='start'),
path('get-questions/start', views.get_questions, {'is_start': True}, name='get-questions'),
path('get-questions', views.get_questions, {'is_start': False}, name='get-questions'),
path('get-answer', views.get_answer, name='get-answer'),
path('get-finish', views.get_finish, name='get-finish'),
]
Add your questions and answers database structure
Add models.py
- Copy the below into
sim/models.py
:
from django.db import models
class Quiz(models.Model):
name = models.CharField(max_length=300)
class Question(models.Model):
quiz = models.ForeignKey(Quiz, on_delete=models.CASCADE)
text = models.CharField(max_length=300)
class Answer(models.Model):
question = models.ForeignKey(Question, on_delete=models.CASCADE)
text = models.CharField(max_length=300)
is_correct = models.BooleanField(default=False)
- Run the below to create the database table:
python manage.py makemigrations
python manage.py migrate
Load quiz data into your database
The slow way (Not recommended)
-
Load data manually by:
-
creating a Django superuser and adding data through the Django admin, or
- adding data through the Django shell, or
- adding data directly into your database using SQL
The fast way (Recommended)
- Load a batch of data into your database using the Django
loaddata
management command.
It is much faster to load data into your database as an entire batch, rather than adding rows individually.
Even if you want to specific questions and answers, I recommend editing the yaml file and then loading everything into your database.
Generally useful technique:
Loading data into your database is a useful general technique.
It's likely that you'll need to load data into a Django database at some point (I've done it many times for different products).
We'll use yaml because I find the syntax easy to ready and to write. You can use JSON or XML if you prefer.
Doing the fast way by using loaddata
with yaml
- Create a file
quiz_data.yaml
in theroot
folder. Here's some sample quiz data I wrote to get you started:
Click to see the sample quiz data (Copy button is at the bottom)
- model: sim.quiz
pk: 1
fields:
name: Fundamental laws
- model: sim.question
pk: 1
fields:
quiz: 1
text: What does Newton's First Law of Motion state?
- model: sim.answer
pk: 1
fields:
question: 1
text: Every object in motion will change its velocity unless acted upon by an external force
is_correct: false
- model: sim.answer
pk: 2
fields:
question: 1
text: For every action, there is an equal and opposite reaction
is_correct: false
- model: sim.answer
pk: 3
fields:
question: 1
text: The force acting on an object is equal to the mass of that object times its acceleration
is_correct: false
- model: sim.answer
pk: 4
fields:
question: 1
text: An object at rest stays at rest, and an object in motion stays in motion with the same speed and in the same direction unless acted upon by an unbalanced force
is_correct: true
- model: sim.question
pk: 6
fields:
quiz: 1
text: An object moves in a circular path at constant speed. According to Newton's laws, which of the following is true about the force acting on the object?
- model: sim.answer
pk: 21
fields:
question: 6
text: The force acts towards the center of the circular path, keeping the object in motion.
is_correct: true
- model: sim.answer
pk: 22
fields:
question: 6
text: The force acts in the direction of the object's motion, accelerating it.
is_correct: false
- model: sim.answer
pk: 23
fields:
question: 6
text: No net force acts on the object since its speed is constant.
is_correct: false
- model: sim.answer
pk: 24
fields:
question: 6
text: The force acts away from the center, balancing the object's tendency to move outward.
is_correct: false
- model: sim.question
pk: 7
fields:
quiz: 1
text: When the temperature of an ideal gas is held constant, and its volume is halved, what happens to its pressure?
- model: sim.answer
pk: 25
fields:
question: 7
text: It doubles.
is_correct: true
- model: sim.answer
pk: 26
fields:
question: 7
text: It halves.
is_correct: false
- model: sim.answer
pk: 27
fields:
question: 7
text: It remains unchanged.
is_correct: false
- model: sim.answer
pk: 28
fields:
question: 7
text: It quadruples.
is_correct: false
- model: sim.question
pk: 8
fields:
quiz: 1
text: In a closed system where two objects collide and stick together, what happens to the total momentum of the system?
- model: sim.answer
pk: 29
fields:
question: 8
text: It increases.
is_correct: false
- model: sim.answer
pk: 30
fields:
question: 8
text: It decreases.
is_correct: false
- model: sim.answer
pk: 31
fields:
question: 8
text: It remains unchanged.
is_correct: true
- model: sim.answer
pk: 32
fields:
question: 8
text: It becomes zero.
is_correct: false
- model: sim.question
pk: 9
fields:
quiz: 1
text: According to the Second Law of Thermodynamics, in which direction does heat naturally flow?
- model: sim.answer
pk: 33
fields:
question: 9
text: From an object of lower temperature to one of higher temperature.
is_correct: false
- model: sim.answer
pk: 34
fields:
question: 9
text: From an object of higher temperature to one of lower temperature.
is_correct: true
- model: sim.answer
pk: 35
fields:
question: 9
text: Equally between two objects regardless of their initial temperatures.
is_correct: false
- model: sim.answer
pk: 36
fields:
question: 9
text: Heat does not flow; it remains constant in an isolated system.
is_correct: false
- model: sim.question
pk: 10
fields:
quiz: 1
text: According to the principle of wave-particle duality, how can the behavior of electrons be correctly described?
- model: sim.answer
pk: 37
fields:
question: 10
text: Electrons exhibit only particle-like properties.
is_correct: false
- model: sim.answer
pk: 38
fields:
question: 10
text: Electrons exhibit only wave-like properties.
is_correct: false
- model: sim.answer
pk: 39
fields:
question: 10
text: Electrons can exhibit both wave-like and particle-like properties, depending on the experiment.
is_correct: true
- model: sim.answer
pk: 40
fields:
question: 10
text: Electrons behave neither like waves nor like particles.
is_correct: false
Load the data into your database
- Run the below to load the data into your Django database. It will overwrite any existing data:
python manage.py loaddata quiz_data.yaml
For more information on loading data into your Django database, see the Django docs page on providing data for models
Q. I want to export my Django database to yaml to add some data manually. How can I do that nicely?
This easy to do. Run the below to export your database's content to a yaml file. This will overwrite the file `quiz_data.yaml` with the data from your database.python manage.py dumpdata --natural-foreign --natural-primary --exclude=auth --exclude=contenttypes --indent=4 --format=yaml > quiz_data.yaml
You can then add data manually to the yaml file, and then load it back into your database using the `loaddata` command.
See the [page about loading data into your Django database in the docs](https://docs.djangoproject.com/en/5.0/howto/initial-data/) for more information
Run our app 👨🚀
If running locally
If you're running the app locally, run the below to start the server:
python manage.py runserver
If running on Replit
If you're using Replit (like I am in the video), do the following:
- Search your files with the search bar for your
.replit
file. Paste in the below:
entrypoint = "manage.py"
modules = ["python-3.10:v18-20230807-322e88b"]
run = "python manage.py runserver 0.0.0.0:3000"
[nix]
channel = "stable-23_05"
[unitTest]
language = "python3"
[gitHubImport]
requiredFiles = [".replit", "replit.nix"]
[deployment]
run = "python3 manage.py runserver 0.0.0.0:3000"
deploymentTarget = "cloudrun"
[[ports]]
localPort = 3000
externalPort = 80
- Update your
core/settings.py
file to: - Change your allowed hosts to this:
ALLOWED_HOSTS = ['.replit.dev']
- Add the line:
CSRF_TRUSTED_ORIGINS = ['https://*.replit.dev']
- Run the app by clicking the green "Run" button at the top of the screen.
- Click the "Open in a new tab" button to see your app running.
Bonus: Generate good quiz data using an LLM
I used ChatGPT to generate more quiz data. It worked well. My approach:
- Get a sample of existing quiz data in yaml format (Such as I gave you above)
- Give the sample to ChatGPT and ask it to generate more data
I used the below prompt to generate more quiz data:
I'm creating a quiz app with questions and answers. Here's a sample of data in yaml format.
Write an additional quiz on fundamental laws of computer science, which would benefit every programmer. Please use the same yaml format.
---
- model: sim.quiz
pk: 1
fields:
name: Fundamental laws
- model: sim.question
pk: 1
fields:
quiz: 1
text: What does Newton's First Law of Motion state?
- model: sim.answer
pk: 1
<...>
Vary the prompt. E.g., I later added: "Avoid questions which just check the name of something" as I wanted questions to test understanding, not memorization.
Just as before, load the data into your database using the loaddata
command as above.
This will give you interesting quiz data to use in your app, potentially to learn a new topic 🙂
Here's a clip of the quiz using the output I get from ChatGPT:
Some raw output from ChatGPT:
Congrats - You've created a quiz app with HTMX and Django 🎉
You've just built a dynamic, multi-stage quiz application using Django and HTMX.
As you can imagine, you can use this approach to build all sorts of multi-stage forms, not just quizzes.
Future things you could add include:
- a progress bar showing how much of the quiz is complete
- user login and tracking of user scores, perhaps by adding Google sign in. Guide here: The simplest way to add Google sign-in to your Django app ✍️
- multiplayer real-time quizzes where users can compete against each other. You could build on my guide here: The simplest way to build an instant messaging app with Django 🌮
P.S - Build your Django frontend even faster
I want to release high-quality products as soon as possible. Probably like you, I want to make my Django product ideas become real as soon as possible.
That's why I built Photon Designer - an entirely visual editor for building Django frontend at the speed that light hits your eyes. Photon Designer outputs neat, clean Django templates 💡