DATE
USERNAME
COMMENT
# models.py
from django.conf import settings
from django.db import models
class Player(models.Model):
player = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
score = models.PositiveSmallIntegerField(default=0)
def __str__(self):
return self.player.first_name + " " + self.player.last_name
class Category(models.Model):
category = models.CharField(max_length=20)
def __str__(self):
return self.category
class Question(models.Model):
question = models.CharField(max_length=200)
value = models.PositiveSmallIntegerField()
category = models.ForeignKey(Category, on_delete=models.CASCADE)
correct_answer = models.OneToOneField('Answer', on_delete=models.CASCADE, related_name='correct_answer', null=True)
def __str__(self):
return self.category.category + ": " + self.question
class Answer(models.Model):
answer = models.CharField(max_length=200)
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answer')
def __str__(self):
return self.question.question + ": " + self.answer
# forms.py
from django import forms
class QuizForm(forms.Form):
category_pk = forms.IntegerField(min_value=0)
question_pk = forms.IntegerField(min_value=0)
answer_pk = forms.IntegerField(min_value=0)
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.contrib.auth.decorators import login_required
from .models import Player, Category, Question, Answer
from .forms import QuizForm
@login_required
def quiz(request):
player, created = Player.objects.get_or_create(player=request.user)
categories = Category.objects.all()
questions = Question.objects.all()
answers = Answer.objects.all()
context = {
'player': player,
'categories': categories,
'questions': questions,
'answers': answers,
}
return render(request, "quiz/quiz.html", context)
{% extends 'quiz/base.html' %}
{% load crispy_forms_tags %}
{% load static %}
{% block title %}Quiz{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{% static 'quiz/css/quiz.css' %}">
{% endblock styles %}
{% block content %}
<h1>Quiz</h1>
{% if user.is_authenticated %}
<div style="text-align: right;"><a href="{% url 'logout' %}">Logout</a></div>
{% endif %}
<br>
<p>Ready to take a quiz, {{ user.first_name }}. To begin, select a category.</p>
<form action="#" method="post">
{% csrf_token %}
<select class="form-select" name="category_pk">
<option selected disabled>Category</option>
{% for category in categories %}
<option value="{{ category.pk }}">{{ category }}</option>
{% endfor %}
</select>
<br>
<select class="form-select" name="question_pk">
<option selected disabled value="0">Question</option>
{% for question in questions %}
<option value="{{ question.pk }}">{{ question }}</option>
{% endfor %}
</select>
<br>
<select class="form-select" name="answer_pk">
<option selected disabled value="0">Answer</option>
{% for answer in answers %}
<option value="{{ answer.pk }}">{{ answer }}</option>
{% endfor %}
</select>
<br>
<p>Score: <strong>{{ player.score }}</strong> </p>
<input class="btn btn-primary" type="submit" value="submit">
</form>
{% endblock content %}
{% block javascript %}
<script src="{% static 'quiz/js/quiz.js' %}"></script>
{% endblock javascript %}
username: admin, password: django-admin
(or just create a new user by clicking on 'Signup'), and you see a big problem. I've seeded the database with 4 categories, each with 4 questions, and each question with 4 answer choices. That's 64 answers, and the user can select any of them, without even selecting a category first! Of course, we could have the form just send the category and then have our view filter out the questions and send it back to the client, but that means a page reload, which is what we are trying to avoid.
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from .models import Player, Category, Question, Answer
from .forms import QuizForm
import json
@login_required
def get_questions(request, category_pk):
questions = Question.objects.filter(category=category_pk)
return JsonResponse(list(questions.values()), safe=False)
@login_required
def get_answers(request, question_pk):
answers = Answer.objects.filter(question=question_pk)
return JsonResponse(list(answers.values()), safe=False)
@login_required
def quiz(request):
player, created = Player.objects.get_or_create(player=request.user)
categories = Category.objects.all()
questions = Question.objects.all()
answers = Answer.objects.all()
context = {
'player': player,
'categories': categories,
'questions': questions,
'answers': answers,
}
return render(request, "quiz/quiz.html", context)
return JsonResponse({'filtered-answers': list(answers.values())})
# urls.py
from django.urls import path
from . import views
app_name = 'quiz'
urlpatterns = [
path('', views.quiz, name='quiz'),
path('get_questions/<int:category_pk>', views.get_questions, name='get_questions'),
path('get_answers/<int:question_pk>', views.get_answers, name='get_answers'),
]
{% extends 'quiz/base.html' %}
{% load crispy_forms_tags %}
{% load static %}
{% block title %}Quiz{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{% static 'quiz/css/quiz.css' %}">
{% endblock styles %}
{% block content %}
<h1>Quiz</h1>
{% if user.is_authenticated %}
<div style="text-align: right;"><a href="{% url 'logout' %}">Logout</a></div>
{% endif %}
<br>
<p>Ready to take a quiz, {{ user.first_name }}. To begin, select a category.</p>
<form action="#" method="post">
{% csrf_token %}
<select class="form-select" name="category_pk" id="category">
<option selected disabled>Category</option>
{% for category in categories %}
<option value="{{ category.pk }}">{{ category }}</option>
{% endfor %}
</select>
<br>
<select class="form-select" name="question_pk" id="question">
<option selected disabled value="0">Question</option>
</select>
<br>
<select class="form-select" name="answer_pk" id="answer">
<option selected disabled value="0">Answer</option>
</select>
<br>
<p>Score: <strong>{{ player.score }}</strong> </p>
<input class="btn btn-primary" type="submit" value="submit" id="submit">
</form>
{% endblock content %}
{% block javascript %}
<script src="{% static 'quiz/js/quiz.js' %}"></script>
{% endblock javascript %}
id
attribute to the each of the select tags as well as the submit button. This is how we can target them in our JavaScript. Note that we no longer need to send all the questions and answers from the initial view anymore:
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from .models import Player, Category, Question, Answer
from .forms import QuizForm
import json
@login_required
def get_questions(request, category_pk):
questions = Question.objects.filter(category=category_pk)
return JsonResponse(list(questions.values()), safe=False)
@login_required
def get_answers(request, question_pk):
answers = Answer.objects.filter(question=question_pk)
return JsonResponse(list(answers.values()), safe=False)
@login_required
def quiz(request):
player, created = Player.objects.get_or_create(player=request.user)
categories = Category.objects.all()
context = {
'player': player,
'categories': categories,
}
return render(request, "quiz/quiz.html", context)
// quiz.js
document.addEventListener('DOMContentLoaded', () => {
// Target the select tags in the DOM (the html template)
let category = document.getElementById('category');
let question = document.getElementById('question');
let answer = document.getElementById('answer');
let submit = document.getElementById('submit');
// Hide the questions and answers until the user selects a category
question.style.display = 'none';
answer.style.display = 'none';
// Disable (you could also hide) the button until the form is completed
submit.disabled = true;
////////////////////////////////////////////////////////////////////////
///////////////////////// GET QUESTIONS ////////////////////////////////
////////////////////////////////////////////////////////////////////////
async function get_questions(e) {
answer.style.display = 'none';
answer.value = 0;
question.value = 0;
submit.disabled = true;
fetch(`/get_questions/${e.target.value}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log(`Success: ${data}`);
for (i = 0; i < data.length; i++) {
question.options[i+1] = new Option(data[i].question, data[i].id);
}
question.style.display = 'block';
})
.catch((error) => {
console.error(`Error: ${error}`);
});
}
category.addEventListener('input', get_questions);
////////////////////////////////////////////////////////////////////////
///////////////////////// GET ANSWERS //////////////////////////////////
////////////////////////////////////////////////////////////////////////
async function get_answers(e) {
answer.value = 0;
submit.disabled = true;
fetch(`/get_answers/${e.target.value}`)
.then(response => {
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
return response.json();
})
.then(data => {
console.log(`Success: ${data}`);
for (i = 0; i < data.length; i++) {
answer.options[i+1] = new Option(data[i].answer, data[i].id);
}
answer.style.display = 'block';
})
.catch((error) => {
console.error(`Error: ${error}`);
});
}
question.addEventListener('input', get_answers);
////////////////////////////////////////////////////////////////////////
/////////////////////// Enable/Disable SUBMIT //////////////////////////
////////////////////////////////////////////////////////////////////////
answer.addEventListener('input', () => {
submit.disabled = false;
});
});
question.options[i+1] = new Option(data[i].question, data[i].id);
Ctrl
and press F5
. For other browsers, computers, and further instructions, check out https://en.wikipedia.org/wiki/Wikipedia:Bypass_your_cache. While the code should work, you will notice two problems. First, when the form is submitted, the page reloads, but no information is sent to our inital quiz view. No score is kept. Second, we want to make it so that a user can answer a question only once.
questions_answered
and categories_done
to handle these in our models:
# models.py
from django.conf import settings
from django.db import models
class Player(models.Model):
player = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
questions_answered = models.ManyToManyField('Question', blank=True)
categories_done = models.ManyToManyField('Category', blank = True)
score = models.PositiveSmallIntegerField(default=0)
def __str__(self):
return self.player.first_name + " " + self.player.last_name
class Category(models.Model):
category = models.CharField(max_length=20)
def __str__(self):
return self.category
class Question(models.Model):
question = models.CharField(max_length=200)
value = models.PositiveSmallIntegerField()
category = models.ForeignKey(Category, on_delete=models.CASCADE)
correct_answer = models.OneToOneField('Answer', on_delete=models.CASCADE, related_name='correct_answer', null=True)
def __str__(self):
return self.category.category + ": " + self.question
class Answer(models.Model):
answer = models.CharField(max_length=200)
question = models.ForeignKey(Question, on_delete=models.CASCADE, related_name='answer')
def __str__(self):
return self.question.question + ": " + self.answer
Ctrl-c
), and run migrations:
python manage.py makemigrations
python manage.py migrate
python manage.py runserver
Now we can modify our view to update the player's score if the player has answered correctly, update which question the player has answered, and finally check if all th questions in that category have now been answered by the player.
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from .models import Player, Category, Question, Answer
from .forms import QuizForm
import json
@login_required
def get_questions(request, category_pk):
questions = Question.objects.filter(category=category_pk)
return JsonResponse(list(questions.values()), safe=False)
@login_required
def get_answers(request, question_pk):
answers = Answer.objects.filter(question=question_pk)
return JsonResponse(list(answers.values()), safe=False)
@login_required
def quiz(request):
player, created = Player.objects.get_or_create(player=request.user)
categories = Category.objects.all()
# I'm not sending this form to the template because I don't want the entire
# form to be displayed - I want JavaScript to load parts of the form, but
# I will be using it to check that the information sent through POST is valid
form = QuizForm(request.POST or None)
if form.is_valid():
# Get the data from the submitted form
category = get_object_or_404(Category, pk=request.POST.get('category_pk'))
question = get_object_or_404(Question, pk=request.POST.get('question_pk'))
answer = get_object_or_404(Answer, pk=request.POST.get('answer_pk'))
# Increase the score if the player has answered correctly, as long as
# the player did not previously answer this question
if answer == question.correct_answer and \
question not in player.questions_answered.all():
player.score = player.score + 1
player.save()
# Add the question to the questions the player has answered
player.questions_answered.add(question)
# Check to see if ALL questions in this category have now been answered
# Using a set since I don't care about order, but there should be no
# duplicates, and python sets do not allow duplicates!
questions_in_this_category_answered = set(player.questions_answered.filter(category=category))
all_questions_in_this_category = set(Question.objects.filter(category=category))
# If the player has answered ALL the questions of this category, then
# add it to categories_done. We can easily check this by seeing if
# the two sets are equal, the set that includes all the questions answered
# of the particular category and all the questions in the category
# JavaScript will handle this information -
# it will disable categories in categories_done
if questions_in_this_category_answered == all_questions_in_this_category:
player.categories_done.add(category)
return redirect('quiz:quiz')
context = {
'player': player,
'categories': categories,
'categories_done': player.categories_done,
}
return render(request, "quiz/quiz.html", context)
There's a lot that we added in this view. I've tried to explain this in the comments above. If we run the project now, it will indeed capture the score, the questions answered, and the categories done to our database, but that will not prevent the user from answering the same questions over and over again. We now need to get JavaScript to disable questions that have already been answered, and categories whose questions have all been answered by the player.
<!-- quiz.html -->
{% extends 'quiz/base.html' %}
{% load crispy_forms_tags %}
{% load static %}
{% block title %}Quiz{% endblock %}
{% block styles %}
<link rel="stylesheet" href="{% static 'quiz/css/quiz.css' %}">
{% endblock styles %}
{% block content %}
<h1>Quiz</h1>
{% if user.is_authenticated %}
<div style="text-align: right;"><a href="{% url 'logout' %}">Logout</a></div>
{% endif %}
<br>
<p>Ready to take a quiz, {{ user.first_name }}. To begin, select a category.</p>
<form action="#" method="post">
{% csrf_token %}
<select class="form-select" name="category_pk" id="category">
<option selected disabled>Category</option>
{% for category in categories %}
{% if category not in categories_done.all %}
<option value="{{ category.pk }}">{{ category }}</option>
{% else %}
<option disabled value="{{ category.pk }}">{{ category }}</option>
{% endif %}
{% endfor %}
</select>
<br>
<select class="form-select" name="question_pk" id="question">
<option selected disabled value="0">Question</option>
</select>
<br>
<select class="form-select" name="answer_pk" id="answer">
<option selected disabled value="0">Answer</option>
</select>
<br>
<p>Score: <strong>{{ player.score }}</strong> </p>
<input class="btn btn-primary" type="submit" value="submit" id="submit">
</form>
{% endblock content %}
{% block javascript %}
<script src="{% static 'quiz/js/quiz.js' %}"></script>
{% endblock javascript %}
Now, to disable the questions answered, we have a little bit more work to do because we don't know which category the player is going to select. At this point you might be wondering, why not just send all the questions the player has answered now. That would make things simpler and avoid any JavaScript.
get_questions_answered
view functin added below:
# views.py
from django.shortcuts import render, redirect, get_object_or_404
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from .models import Player, Category, Question, Answer
from .forms import QuizForm
import json
@login_required
def get_questions(request, category_pk):
questions = Question.objects.filter(category=category_pk)
return JsonResponse(list(questions.values()), safe=False)
@login_required
def get_answers(request, question_pk):
answers = Answer.objects.filter(question=question_pk)
return JsonResponse(list(answers.values()), safe=False)
@login_required
def get_questions_answered(request):
questions_answered = []
for question in request.user.player.questions_answered.all():
questions_answered.append(question.id)
return JsonResponse(questions_answered, safe=False)
@login_required
def quiz(request):
player, created = Player.objects.get_or_create(player=request.user)
categories = Category.objects.all()
# I'm not sending this form to the template because I don't want the entire
# form to be displayed - I want JavaScript to load parts of the form, but
# I will be using it to check that the information sent through POST is valid
form = QuizForm(request.POST or None)
if form.is_valid():
# Get the data from the submitted form
category = get_object_or_404(Category, pk=request.POST.get('category_pk'))
question = get_object_or_404(Question, pk=request.POST.get('question_pk'))
answer = get_object_or_404(Answer, pk=request.POST.get('answer_pk'))
# Increase the score if the player has answered correctly, as long as
# the player did not previously answer this question
if answer == question.correct_answer and \
question not in player.questions_answered.all():
player.score = player.score + 1
player.save()
# Add the question to the questions the player has answered
player.questions_answered.add(question)
# Check to see if ALL questions in this category have now been answered
# Using a set since I don't care about order, but there should be no
# duplicates, and python sets do not allow duplicates!
questions_in_this_category_answered = set(player.questions_answered.filter(category=category))
all_questions_in_this_category = set(Question.objects.filter(category=category))
# If the player has answered ALL the questions of this category, then
# add it to categories_done. We can easily check this by seeing if
# the two sets are equal, the set that includes all the questions answered
# of the particular category and all the questions in the category
# JavaScript will handle this information -
# it will disable categories in categories_done
if questions_in_this_category_answered == all_questions_in_this_category:
player.categories_done.add(category)
return redirect('quiz:quiz')
context = {
'player': player,
'categories': categories,
'categories_done': player.categories_done,
}
return render(request, "quiz/quiz.html", context)
Did you forget to add a path in your urls.py
to handle that request? No worries, that is common. In Django you have a path in urls.py
that connects to a view in your views.py
, which renders a template in your templates folder. At least that's in FBV (function based views). Don't get me started on CBV (class based views). CBV's are a powerful, different way of making code reusable and makes it easier to use the DRY (Don't Repeat Yourself) idea in python and Django. But that's a whole other lesson (and frankly, I have literally no experience writing CBV's). So, here is the urls.py
now with the new view get_questions_answered
path):
from django.urls import path
from . import views
app_name = 'quiz'
urlpatterns = [
path('', views.quiz, name='quiz'),
path('get_questions/<int:category_pk>', views.get_questions, name='get_questions'),
path('get_answers/<int:question_pk>', views.get_answers, name='get_answers'),
path('get_questions_answered', views.get_questions_answered, name='get_questions_answered'),
]
The final step is to use JavaScript to target the options in the select tags and disable those questions already answered, and the categories completely done by the player:
// quiz.js
document.addEventListener('DOMContentLoaded', () => {
////////////////////////////////////////////////////////////////////
//////////////////////// INITIALIZE STUFF //////////////////////////
////////////////////////////////////////////////////////////////////
const category = document.getElementById('category');
const question = document.getElementById('question');
const answer = document.getElementById('answer');
const submit = document.getElementById('submit');
question.style.display = 'none';
answer.style.display = 'none';
submit.disabled = true;
////////////////////////////////////////////////////////////////////
//////////////////////// GET QUESTIONS /////////////////////////////
////////////////////////////////////////////////////////////////////
async function get_questions(e) {
answer.style.display = 'none';
answer.value = 0;
question.value = 0;
submit.disabled = true;
const get_questions_answered = await fetch('/get_questions_answered');
const questions_answered = await get_questions_answered.json();
const get_questions = await fetch('/get_questions/' + e.target.value);
const questions = await get_questions.json();
for (let i = 0; i < questions.length; i++) {
question.options[i+1] = new Option(questions[i].question, questions[i].id);
if (questions_answered.includes(questions[i].id))
question.options[i+1].disabled = true;
}
question.style.display = 'block';
}
category.addEventListener('input', get_questions);
////////////////////////////////////////////////////////////////////
//////////////////////// GET ANSWERS ///////////////////////////////
////////////////////////////////////////////////////////////////////
async function get_answers(e) {
answer.value = 0;
submit.disabled = true;
const response = await fetch('/get_answers/' + e.target.value);
const answers = await response.json();
for (let i = 0; i < answers.length; i++)
answer.options[i+1] = new Option(answers[i].answer, answers[i].id);
answer.style.display = 'block';
}
question.addEventListener('input', get_answers);
////////////////////////////////////////////////////////////////////
////////////////////// Enable/Disable SUBMIT ///////////////////////
////////////////////////////////////////////////////////////////////
answer.addEventListener('input', () => {
submit.disabled = false;
});
});
I hope that you actually learned how to incorporate JavaScript in general, AJAX and fetch() in particular to make Django dynamic. Look, nowadays, there are frameworks for everything. Django is just a python framework. Frameworks are good. They make routine coding easier by not having every coder try to reinvent the wheel; come up with new ways to do things that have been done so many times before that frameworks have come about to take care of them. And frameworks do their job well! I certainly don't have the knowledge or patience to start with pure python and create a website from that! However, sometimes, frameworks like htmx (and I'm not knocking it- it is a powerful, useful tool that, depending on what you're looking to do, might be well worth your while), are overkill, and can limit flexibility. I've use nothing aside from vanilla JavaScript here, and the code is less thant 100 lines, powerful, and very flexible, so you can adjust it to your particular needs. Moral of the story: If a framework fits your needs (and you can often find one that does), then use it!, don't reinvent the wheel. But, at least understand the basics for when you need to make something custom. And after all, why would anyone hire you as a developer if you can't make custom things!