OCT 1, 2016

Making a quizz website - Part 1

About a week ago, I decided to make a quizz website, because I wanted something free, and also so I could get the experience from it. Oh boy what did I get myself into...

Note

This isn't going to be a tutorial, it's just showing how the website works.

Set up

I decided to use Django for this, because it's my favourite web framework, written in my favourite language. That's it on the back-end, at least for now.

Starting out

Being Django, I had to create a few models to hold the quizz information.

Quizz model

This will hold information about the quizz (author, title, description, url). It's pretty simple:

class Quizz(models.Model):
    quizz_uid = models.SlugField(max_length=32, default=generate_uid, unique=True)

    author = models.ForeignKey(User, null=True, blank=True)
    quizz_name = models.CharField(max_length=140)
    quizz_description = models.TextField(blank=True)

    pub_date = models.DateTimeField(default=timezone.now)

    def __str__(self):
        return self.quizz_name

    class Meta:
        verbose_name_plural = "Quizzes"

Notice how we use a SlugField instead of a CharField for the url: This is just to make sure the quiz_uid will convert well to a url. You can find more information about it here.

generate_uid is a simple function that generates a random 32 character long string:

def generate_uid():
    uid = "".join([random.choice(string.ascii_letters+string.digits+"_") for _ in range(32)])
    return uid

Later, I need to make this check if the uid is already in use.

We also set a verbose_name_plural, to make the admin call it Quizzes instead of Quizzs.

Question model

This will hold information about a specific question (duh), such as: - Points for correct - Points for incorrect - Question name - Question description

class Question(models.Model):
    quizz = models.ForeignKey(Quizz)

    question_title = models.CharField(max_length=256)
    question_description = models.TextField(blank=True)
    points_correct = models.IntegerField(default=5)
    points_incorrect = models.IntegerField(default=-2)

    def __str__(self):
        return self.question_title

I don't think this needs much explaining.

Option model

This will hold information about... you guessed it, the options for a particular question!

class Option(models.Model):
    question = models.ForeignKey(Question)

    option_text = models.CharField(max_length=140)
    correct = models.BooleanField(default=False)

    def __str__(self):
        return self.option_text

Noticed how we didn't set a correct answer on the Question model? That's because we are setting if an option is correct on the option model. This allows us to set multiple correct options!

I also made admin pages for each of these models, but I think you can do that yourself.

Showing the quizzes

This is where things start to get interesting, we're making a front-end!

I wrote my own CSS for this, but that doesn't matter much so I won't be showing it here (also, it's over 140 lines long...)

For the initial page, which will be at /quizz/[uid]/, I'm just showing the title, the description and a simple 'Start quizz' button:

Template:

<h1>{{ quizz.quizz_name }}</h1>
<p>{{ quizz.quizz_description }}</p>
<a href="{% url 'quizzes:startquizz' quizz.quizz_uid %}" class="button">Start quizz</a>

View:

def quizz(request, quizzid):
    quizz = get_object_or_404(Quizz, quizz_uid=quizzid)
    return render(request, "quizzdescription.html", {"quizz": quizz})

I need better names for my quizzes

Obviosuly the HTML is more complex on the picture

Starting the quizz

This is by far the most complicated part of the while thing. Here's what we're doing: - Giving the user a unique ID - (From the template using AJAX) Request a random question - Return a question that hasn't been done yet - Send the selected option back to the server - Check wether the option is correct - Return the results for the previous question, as well as a new question - When the user is finished, redirect them to another page where they can see their score

Sounds complicated? It is. Oh well. Firstly, let's handle the request for the quizz page (/quizz/[quizz uid]/do/):

def doquizz(request, quizzid):
    quizz = get_object_or_404(Quizz, quizz_uid=quizzid)

    r = redis.StrictRedis(host=getattr(settings, 'REDIS_HOST', 'localhost'), port=getattr(settings, 'REDIS_PORT', 6379), db=0)

    db_id = "".join([random.choice(string.ascii_letters + string.digits + "-_") for _ in range(64)])

    while r.exists(db_id):
        db_id = "".join([random.choice(string.ascii_letters + string.digits + "-_") for _ in range(64)])

    r.set('%s:quizz' % db_id, quizz.id)
    r.expire('%s:quizz' % db_id, 3600)

    return render(request, "doquizz.html", {"quizz": quizz, "db_id": db_id})

db_id is a random and unique 64-bit string that will be used to track the user accross questions. This will be stored in a redis database for a period of an hour. This is assigned to the id of the quizz.

Now for the template:

<input type="hidden" name="db_id" id="dbid" value="{{ db_id }}">
<h3 class="quizz-title">{{ quizz.quizz_name }}</h3>
<div class="spinner" id="loading">
    Loading
</div>

<div class="hidden" id="quizz">
    {% verbatim %}
        <h2 class="question-title">{{ question.text }}</h2>
        <p class="question-description" v-if="question.description">{{ question.description }}</p>
        <div class="options">
            <a href="#" v-for="option in question.options" class="button" v-on:click.prevent="selectoption(option)" id="{{ option.option_id }}">{{ option.option_text }}</a>
        </div>
    {% endverbatim %}
</div>
{% load static %}
<script src="&lt;a href=" https:="" <a="" href="http://ajax.googleapis.com" target="_blank">ajax.googleapis.com="" ajax="" libs="" jquery="" 3.1.0="" jquery.min.js"="" target="_blank">https://ajax.googleapis.com/ajax/libs/jquery/3.1.0/jquery.min.js"></script>
<script src="{% static 'js/vue.js' %}"></script>
<script src="{% static 'js/app.js' %}"></script>

Woah, what's going on here? What's with all of those v-attributes?

This is vue.js. Not going to go into much detail, but the only reason I'm using it is because it allows me to easilly update the DOM. verbatim is a Django tag that ignores all of the template syntax, much like HTML's <pre>.

The reason we show a Loading screen is because we are fetching the question data on the background. Let's take a look at the JS:

dbid = $('#dbid').value();

initialRequest = new XMLHttpRequest();
initialRequest.addEventListener('load', initialRequestComplete);

initialRequest.open("GET", "/question/" + dbid)
initialRequest.send()

function initialRequestComplete(evt) {
    // ...
}

What's going on here?

First, we get the dbid from the hidden field. After that, we send a GET request to /question/[dbid], and when that's finished we call initialRequestcomplete() (we'll take a look at that later).

Let's look at the view behind /question/[dbid]:

@csrf_exempt
def getquestion(request, trackid):
    r = redis.StrictRedis(host=getattr(settings, 'REDIS_HOST', 'localhost'), port=getattr(settings, 'REDIS_PORT', 6379), db=0)
    quizz = get_object_or_404(Quizz, id=r.get('%s:quizz' % trackid))

    response = {}

    # ...

    done_questions = r.hgetall('%s:questions' % trackid)

    questions = quizz.question_set.all()


    question = random.choice(questions)
    while bytes(str(question.id), 'utf-8') in done_questions:
        print(question.id)
        question = random.choice(questions)

    #...

    qID = "".join([random.choice(string.ascii_letters + string.digits + "-_") for _ in range(16)])
    while r.exists(qID):
        qID = "".join([random.choice(string.ascii_letters + string.digits + "-_") for _ in range(16)])

    r.set('question:%s' % qID, question.id)
    r.expire('question:%s' % qID, 3600)
    options = []

    for option in question.option_set.all():
        oID = "".join([random.choice(string.ascii_letters + string.digits + "-_") for _ in range(16)])
        while r.exists(oID):
            oID = "".join([random.choice(string.ascii_letters + string.digits + "-_") for _ in range(16)])

        r.set('option:%s' % oID, option.id)
        r.expire('option:%s' % oID, 3600)
        options.append({'option_id': oID, 'option_text': option.option_text})

    random.shuffle(options)

    response['quizz'] = {
            'uid': quizz.quizz_uid,
            'name': quizz.quizz_name
        }
    response['question'] = {
            'id': qID,
            'text': question.question_title,
            'options': options,
        }
    response['extra'] = {
            'totalquestions': len(questions),
            'currentquestion': len(done_questions)
        }

    if question.question_description != "":
        response['question']['description'] = question.question_description

    return JsonResponse(response)

And that's a trimmed down version.

Alright, let's try to figure out what this mess does:

Firstly, we get the quizz corresponding to the tracking id. After that, we check which questions have been completed, by getting the value of the redis hash [trackid]:questions, which assigns a question id to the chosen answer.

After we have that, we choose a random question that hasn't been completed yet, and we give it a unique question id on the redis DB.

We then get the options for that question, assign each of them a random id (which also goes on the redis database), and then we shuffle them around.

Finally, we put all of this data on the response, and return it as a JSONResponse.

Now, let's take a look at the code that parses this on the client-side:

// ...

function initialRequestComplete(evt) {
    $('#loading').addClass('hidden');
    $('#quizz').removeClass('hidden');
    vm = new Vue({
        el: '#quizz',
        data: JSON.parse(initialRequest.response),
        methods: {
            // ...
        }
    })
}

What's going on here?

  • First, we hide the loading screen and show the quizz.
  • Then, we create a new Vue object, on the #quizz element. We use the response as data, because the template is already optimized to use this.

Alright, this one was simple.

Here's what the site looks like right now:

I need more descriptive names

However, if we select an option, nothing happens! Let's fix that.

Selecting an option

We can see from before that the options have a vue onclick event that calls selectoption. Let's look at that function:

// ...
methods: {
    selectoption: function(option, event) {
        doRequest = new XMLHttpRequest();
        doRequest.addEventListener('load', complete);
        doRequest.open('POST', '/question/' + dbid, true);

        params = 'question=' + this.question.id + '&option=' + option.option_id;

        doRequest.setRequestHeader("Content-type", "application/x-www-form-urlencoded");

        doRequest.send('question=' + this.question.id + '&option=' + option.option_id);

        function complete(evt) {
            // ...
        }
    }
}

Here, we send a POST request to the same URL as before. However, we are setting a few parameters: question and option. Both of these are the IDs we received before.

We need to handle this on the backend now, so let's add a bit of code to getquestion:

if request.method == "POST":
    if 'question' in request.POST and 'option' in request.POST:
        question = r.get('question:%s' % request.POST['question'])
        option = r.get('option:%s' % request.POST['option'])
        if question and option:
            r.delete('question:%s' % request.POST['question'])
            r.delete('option:%s' % request.POST['option'])

            r.hset('%s:questions' % trackid, question, option)
            r.expire('%s:questions' % trackid, 3600)

            correct = Option.objects.get(id=option).correct
            if correct:
                score = Question.objects.get(id=question).points_correct
            else:
                score = Question.objects.get(id=question).points_incorrect

            response['answerinfo'] = {
                'correct': correct,
                'score': score
            }

Here we check if the request is a POST request. If it is, we check if question and option are set, and then we check if they exist in the database.

If they exist, we delete the database entries for them (they're no longer needed), and we set their value on [trackid]:questions.

We then check wether the answer's correct, calculate the score and add that to the response.

Time to handle this on the client side!

function complete(evt) {
    response = JSON.parse(doRequest.response);

    $('#' + option.option_id).addClass(response.answerinfo.correct ? "correct" : "wrong");

    setTimeout(function() {
        // ...
        vm.question = response.question;
        vm.options = response.options;
    }, 1000);
}

Here, we get the response, add a class to the option to show wether it's correct or not, and after 1 second we update the question on screen.

whoops I got it wrong (even though it told me to)

Is it done? Well... at the moment, if all the questions are done, the server will hang while looking for a new question. We should instead redirect to a page with the score. Let's do it then!

if len(done_questions) < len(questions):
    question = random.choice(questions)
    while bytes(str(question.id), 'utf-8') in done_questions:
        print(question.id)
        question = random.choice(questions)
else:
    response['redirect'] = reverse('quizzes:score', args=[trackid])
    return JsonResponse(response)

Here, we're checking wether there are still questions left to answer. If not, we tell the client to redirect to the score page. Only thing left to do is handle the redirect on the client!

if (response.redirect) {
    $('#loading').removeClass('hidden');
    $('#quizz').addClass('hidden');
    window.location = response.redirect;
} else {
    vm.question = response.question;
    vm.options = response.options;
}

Pretty simple: if response.redirect is set, we show the loading screen and redirect to the location specified!

This is getting quite long, so check back for the next part, where we'll show the score and allow the user to create quizzes!