Una aplicació de qüestionaris amb Python, GTK (Glade) i SQLite (Star Wars Quizz!)

Motivació

Sempre pot ser útil una aplicació per a fer qüestionaris, o tests. Avui presento el que podria ser l’esquema inicial, la maqueta, d’una aplicació de qüestionaris.

En realitat, la cosa és més divertida: No cal que us recordi que avui, divendres 18 de desembre de 2015, és l’estrena mundial de “Star Wars: The Force Awakens”. Aquesta és una data esperada per molts fans i, en particular, pel meu fill. Fa uns dies el meu fill va preparar un qüestionari, amb Scratch 1.4, sobre l’univers Star Wars. El qüestionari amb Scratch li va quedar força bé i, inesperadament per ell, jo vaig obtenir la màxima puntuació en fer-lo. Va ser el moment per tenir una conversa, de pare a fill, sobre com es poden fer aquests programes de preguntes i respostes. El resultat és que jo li vaig preparar a ell un qüestionari sobre Star Wars, en mode text, amb Pyhton.

A partir d’aquí vaig pensar en sofisticar-ho una mica, desant les preguntes a una base de dades i fent servir una GUI, en comptes del mode text. El resultat és aquesta maqueta d’aplicació de qüestionaris, que originalment era un qüestionari sobre Star Wars i que, per això, és diu Star Wars Quizz! Quines coses, oi? fet aquest aclariment, només ens resta començar i… que la Força ens acompanyi!

 

Programari utilitzat

He triat per a desenvolupar l’aplicació el llenguatge Python, amb Emacs fent de IDE. He fet servir el constructor d’interfícies gràfiques Glade per a fer el disseny de les finestres de l’aplicació. La combinació de Python i Glade (GTK) és, a la pràctica, un entorn ràpid de desenvolupament.

La base de dades de l’aplicació és SQLite3.

El sistema operatiu és Lubuntu 15.10

 

Descripció funcional

Finestra principal i menú de l’aplicació

L’aplicació és molt senzilla: Es presenta com una finestra amb una barra de menús que conté  dos menús desplegables: “Arxiu” i “Ajuda”.

A “Arxiu” hi ha l’entrada “Nou”, que posa en marxa el qüestionari; i l’entrada “Surt”, que tanca l’aplicació.

A “Ajuda” hi ha l’entrada “Quant a” que presenta una finestra amb el logo de l’aplicació, crèdits i llicència.

 

Diàleg de nom i curs

Quan es fa click a “Nou” s’obre una finestra de diàleg en la que se’ns demana el nom de l’usuari i quin curs fa. Hi han dos botons, “Acceptar” i “Tancar”.

Amb “Acceptar” es prenen les dades introduïdes als camps de text, es tanca la finestra que demana el nom i el curs i es mostra el diàleg amb la primera pregunta.

Amb “Tancar” es tanca el diàleg de nom i curs i es descarta la informació que s’hagués introduït.

 

Diàleg de preguntes

La finestra de diàleg que mostra les preguntes s’encapçala amb el nom de l’usuari i el curs que fa. S’indica quin és el número de pregunta que s’està fent.  En cursiva es mostra la pregunta i, a continuació, quatre possibles respostes. L’usuari marca la resposta, o respostes, correctes activant els checkboxes corresponents.

Fent click en “Següent”, es carrega la següent pregunta si n’hi ha. Si s’ha completat el qüestionari es tanca el diàleg de preguntes i s’obre el diàleg que mostra els resultats del qüestionari.

Fent click a “Tancar” es tanca el diàleg de preguntes i respostes, i es descarta nom, curs i el resultat que s’hagués obtingut fins el moment.

 

Diàleg de resultats

Finalment, la pantalla que mostra els resultats indica el nom i curs de l’usuari i mostra quantes preguntes s’han encertat del total.

La pantalla de resultats té un botó de “Tancar” amb el que  es tanca el diàleg de resultat, es descarta nom, curs i el resultat que s’hagués obtingut fins el moment.

Taules

El primer pas ha estat desenvolupar les taules que suporten la funcionalitat  descrita. Són les taules següents:

CREATE TABLE "topics_questions" (
    "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    "id_topic" INTEGER NOT NULL
);

CREATE TABLE "topics" (
    "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL
);

CREATE TABLE "questions_answers" (
    "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    "id_question" INTEGER NOT NULL,
    "id_answer" INTEGER NOT NULL
);

CREATE TABLE "questions" (
    "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    "question" TEXT
);

CREATE TABLE "question_right_answer" (
    "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    "id_question" INTEGER NOT NULL,
    "id_right_answer" INTEGER NOT NULL
);

CREATE TABLE "answers" (
    "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
    "answer" TEXT
);

No faig servir totes les taules. Algunes estan pensant en futures ampliacions de funcionalitat. Les taules importants són QUESTIONS, que manté les preguntes; ANSWERS, les respostes; QUESTIONS_ANSWERS, taula de relació m-n entre preguntes i respostes; QUESTION_RIGHT_ANSWER, taula m-n entre preguntes i respostes correctes.

Ara mateix només hi ha preguntes sobre Star Wars i llavors es pot simplificar encara més: no he fet servir TOPICS, taula que manté els temes dels qüestionaris; ni TOPICS_QUESTIONS, m-n que relaciona temes i preguntes. Però les taules hi són.

db_loader

Val a dir que la versió original del programa feia servir un questionari mantingut en un parell de llistes de Python. Aleshores, el que he fet és partir d’aquestes llistes per a fer la càrrega inicial de les taules. L’script de càrrega de les taules de l’SQLite ha estat el següent:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sqlite3

questions = (
    u"Com es diu el wookie que acompanya a Han Solo?",
    u"De quina especie són els ossets peluts de la lluna santuari d'Endor?",
    u"A quina batalla va ser destruida la primera Estrella de la Mort?",
    u"Quantes temporades té la serie de The Clone Wars?",
    u"Com es diu la padawan d'Anakin Skywalker?",
    u"Quin és el nom Sith del Comte Dooku?",
    u"De quin planeta és Luke Skywalker?",
    u"A quin planeta és el duel entre Anakin Skywalker i Obi-Wan Kenobi?",
    u"De quin color és l'espasa laser de Mace Windu?",
    u"De quin color és l'espasa laser dels lords Sith?"
)

answers = (
    (2, "Peret", "Chewbacca", "Sr. Spock", "Leia Organa"),
    (1, "Ewoks", "Wookies", "Mandalorians", "Corellians"),
    (4, "Waterloo", "Normandia", "Toth", "Yavin-4"),
    (3, "1000", "5", "6", "7"),
    (3, "Artiom", "Assaj Ventress", "Ahsoka Tano", "Kanan Jarrus"),
    (4, "Darth Sidious", "Darth Turo", "Darth Tenebre", "Darth Tyranus"),
    (2, "Naboo", "Tatooine", "Mandalore", "Alderaan"),
    (1, "Mustafar", "Kamino", "Vulcano", "Warsoom"),
    (4, "Blau", "Verd", "Vermell", "Violeta"),
    (2, "Blanc", "Vermell", "Blau", "Violeta")
)

CONNECTION_STRING = "/home/albert/workspace/python/quizz/db/quizz.db"
INSERT_QUESTION = "insert into questions(question) values (?)"
INSERT_ANSWER = "insert into answers(answer) values (?)"
INSERT_QUESTION_ANSWER = "insert into questions_answers(id_question, id_answer) values (?, ?)"
INSERT_RIGHT_ANSWER = "insert into question_right_answer(id_question, id_right_answer) values (?,?)"

if __name__ == '__main__':
    print "begin"
    conn = sqlite3.connect(CONNECTION_STRING)

    cur = conn.cursor()
    num_question = 0
    
    for question in questions:
        cur.execute(INSERT_QUESTION, (question,))
        id_question = cur.lastrowid

        right_answer = answers[num_question][0]

        for num_answer in range(1,5):
            cur.execute(INSERT_ANSWER, (answers[num_question][num_answer],))
            id_answer = cur.lastrowid

            cur.execute(INSERT_QUESTION_ANSWER, (id_question, id_answer))
            if num_answer == right_answer:
                cur.execute(INSERT_RIGHT_ANSWER, (id_question, id_answer))

        num_question = num_question + 1
        
    conn.commit()
    conn.close()

    print "done!"

El que és interessant de l’script anterior és la llista answers. Es tracta d’una “llista de llistes” (un array multidimensional, en definitiva). En aquesta llista de llistes l’element answers[numPregunta][0] ens diu quin és l’índex de la resposta correcta de la pregunta numPregunta. No he fet una traducció directa de l’estructura de les llistes questions i answers a taules si no que he plantejat unes entitats totalment normalitzades. En la pràctica és el dona més flexibilitat per evolucionar l’aplicació.

Arquitectura de l’aplicació

L’aplicació és molt senzilla i s’hauria pogut resoldre amb un únic mòdul. Però crec que és una bona idea mantenir els bons costums sempre i, per això, he tractat de seguir algunes “bones pràctiques” pel que fa a arquitectura. Essencialment, he procurat seguir el patró MVC i he separat la funcionalitat en quatre mòduls.

quizz.py

Aquest mòdul no fa res més que instanciar el controlador i iniciar l’aplicació.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from controller import Controller

if __name__ == "__main__":
    controller = Controller()
    controller.initApp()

controller.py

El controlador s’implementa amb la classe Controller, molt senzilla, que veu tant la classe GUI com la classe Loader. Realment aquest controlador fa poc més que de pont entre la GUI i les dades carregades amb el Loader. Això és perquè el pas de pantalles està codificat directament als mètodes dels events de la GUI. Seria una bona idea, des del punt de vista d’arquitectura, portar aquesta navegació entre pantalles al controlador.

El codi és el següent:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from gui import GUI
from db import Loader


class Controller():
    gui = None
    db = None
    
    def __init__(self):
        self.db = Loader()
        self.gui = GUI()
        self.gui.setController(self)

    def initApp(self):
        self.db.loadData()
        self.gui.initApp()

    def getQuestion(self, numQuestion):
        return self.db.getQuestion(numQuestion)

    def getNumQuestions(self):
        return self.db.getNumQuestions()

dp.py

Al mòdul db.py implemento la classe Loader. Aquesta classe fa el procés invers de l’script db_loader.py: construeix les llistes questions i answers a partir de les taules de l’aplicació. A més, també afegeix uns mètodes per obtenir el nombre total de preguntes carregades, i per obtenir una pregunta, les seves respostes i la resposta correcta.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sqlite3

class Loader:
    CONNECTION_STRING = "/home/albert/workspace/python/quizz2/db/quizz.db"
    SELECT_QUESTIONS = "select id, question from questions"
    SELECT_ANSWERS = "select a.id, a.answer " + \
                     " from answers a, questions_answers qa " + \
                     " where qa.id_question = ? " + \
                     " and qa.id_answer = a.id"
    SELECT_RIGHT_ANSWER = "select id_right_answer " + \
                          " from question_right_answer " + \
                          " where id_question = ? "

    questions = []
    answer = []
    answers = []
    numQuestions = 0
    
    def loadData(self):
        conn = sqlite3.connect(self.CONNECTION_STRING)
        cur_questions = conn.cursor()
        cur_answers = conn.cursor()
        cur_right_answer = conn.cursor()
    
        cur_questions.execute(self.SELECT_QUESTIONS)
        self.numQuestions = 0
        for row_question in cur_questions:
            # load question
            self.questions.append(row_question[1])
            # load right answer
            cur_right_answer.execute(self.SELECT_RIGHT_ANSWER, (row_question[0],))
            id_right_answer = cur_right_answer.fetchone()[0]
            self.answer = []
            self.answer.append(id_right_answer)
            # load answers for this question
            cur_answers.execute(self.SELECT_ANSWERS, (row_question[0],))
            num_right_answer = 1
            for r_answer in cur_answers:
                self.answer.append(r_answer[1])
                if id_right_answer == r_answer[0]:
                    self.answer[0] = num_right_answer
                num_right_answer = num_right_answer + 1
            self.answers.append(self.answer)
            self.numQuestions = self.numQuestions + 1
        conn.close()
    
    def getQuestion(self, numQuestion):
        return [self.questions[numQuestion-1],
                self.answers[numQuestion-1],
                self.answers[numQuestion-1][0]] 

    def getNumQuestions(self):
        return self.numQuestions
        

Remarcar la utilització de les classes de connexió i de cursor. La paraula Cursor em fa pensar immediatament en els cursors de bases de dades, però a Python la classe Cursors cumpleix més missions que les d’un cursor de base de dades.

GUI.py

Finalment, implemento la classe GUI al mòdul gui.py. Aquesta classe implementa els mètodes de resposta a events de la interfícia gràfica. Tota la interfície gràfica està codificada en un fitxer xml (amb extensió .glade) ies construeix amb Gtk.builder().

L’accés als diferents objectes de la interfícies es fa per nom amb invocacions al mètode get_object(). Amb la referència a l’objecte, es poden fer servir tots els seus mètodes. El procés és molt senzill. Vet aquí el codi:

#!/usr/bin/env python
#-*- coding: utf-8 -*-

from gi.repository import Gtk

class GUI():
    builder = None
    formMain = None
    formNew =None
    formQuestion = None
    formScoring =None
    formAbout = None
    name = ""
    course = ""
    numQuestion = 1
    numQuestions = 0
    rightAnswer = 0
    rightAnswersCounter = 0
    controller = None
    FORMS = "/home/albert/workspace/python/quizz2/forms.glade"

    def setController(self, controller):
        self.controller = controller

    def initApp(self):
        self.builder = Gtk.Builder()
        self.builder.add_from_file(self.FORMS)
        self.builder.connect_signals(self)
        
        self.formMain = self.builder.get_object("applicationwindow1")
        self.formMain.show()
        Gtk.main()
        
    def onClose(self, *args):
        print "onClose"
        Gtk.main_quit(*args)
        exit()
    
    def onMenuitemNou(self, widget):
        self.formNew = self.builder.get_object("dialog1")
        self.formNew.set_transient_for(self.formMain)
        self.numQuestions = self.controller.getNumQuestions()
        self.builder.get_object("entry3").set_text("")
        self.builder.get_object("entry2").set_text("")        
        self.formNew.show()
        print "onMenuitemNouClick"
        
    def onMenuitemSurt(self, widget):
        print "onMenuitemSurtClick"
        Gtk.main_quit()
        exit()
        
    def onMenuitemAbout(self, widget):
        self.formAbout = self.builder.get_object("aboutdialog1")
        self.formAbout.set_transient_for(self.formMain)
        self.formAbout.run()
        self.formAbout.hide()
            
        print "onMenuitemAbout"
        
    def onButton1AcceptarClick(self, widget):
        self.name = self.builder.get_object("entry3").get_text()
        self.course = self.builder.get_object("entry2").get_text()
        self.formNew.hide()

        self.formQuestion = self.builder.get_object("dialog2")
        self.formQuestion.set_transient_for(self.formMain)
        self.setFormQuestion(self.numQuestion)
        self.formQuestion.show()        
        
        print "Nom: %s; Curs: %s" % (self.name, self.course)
        print "onButton1AcceptarClick"

    def setFormQuestion(self, numQuestion):
        [question, answers, self.rightAnswer] = self.controller.getQuestion(numQuestion)
        self.builder.get_object("label8").set_text("%s de %s curs" % (self.name, self.course))
        self.builder.get_object("label3").set_text("Pregunta %d" % numQuestion)
        self.builder.get_object("label4").set_text(question)
        self.builder.get_object("checkbutton1").set_label(answers[1])
        self.builder.get_object("checkbutton2").set_label(answers[2])
        self.builder.get_object("checkbutton3").set_label(answers[3])
        self.builder.get_object("checkbutton4").set_label(answers[4])
        self.builder.get_object("checkbutton1").set_active(False)
        self.builder.get_object("checkbutton2").set_active(False)
        self.builder.get_object("checkbutton3").set_active(False)
        self.builder.get_object("checkbutton4").set_active(False)

    def onButton2CancelarClick(self, widget):
        self.formNew.hide()
        print "onButton2CancelarClick"

    def onButtonSeguir(self, widget):
        # validate right answer. increment question counter,
        myAnswer = (1 * self.builder.get_object("checkbutton1").get_active()) + \
                   (2 * self.builder.get_object("checkbutton2").get_active()) + \
                   (4 * self.builder.get_object("checkbutton3").get_active()) + \
                   (8 * self.builder.get_object("checkbutton4").get_active())
        rightAnswer = pow(2, self.rightAnswer - 1)

        if myAnswer == rightAnswer:
            self.rightAnswersCounter = self.rightAnswersCounter + 1

        self.numQuestion = self.numQuestion + 1

        if self.numQuestion <= self.numQuestions:
            self.setFormQuestion(self.numQuestion)
            self.formQuestion.show()
        else:
            self.formQuestion.hide()
            self.formScoring = self.builder.get_object("dialog3")
            self.formScoring.set_transient_for(self.formMain)
            self.builder.get_object("labelNom").set_text("%s de %s curs" % (self.name, self.course))
            self.builder.get_object("label6").set_text("%d preguntes" % self.rightAnswersCounter)
            self.builder.get_object("label7").set_text("d'un total de %d preguntes" % self.numQuestions)
            self.formScoring.show()        

        print "onButtonSeguir"
        
    def onCancelarQuizz(self, widget):
        self.reset()
        self.formQuestion.hide()
        print "onCancelarClick"

    def onTancarClick(self, widget):
        self.reset()
        self.formScoring.hide()
        print "onTancarClick"

    def reset(self):
        self.name = ""
        self.course = ""
        self.numQuestion = 1
        self.rightAnswer = 0
        self.rightAnswersCounter = 0

Millores
El programa és una maqueta i es pot ampliar de moltes formes. Se m’acuden les següents millores:
– desar nom, curs i resultat obtingut a la BD.
– incorporar un cronòmetre per qüestionari: temps màxim de resolució del qüestionari.
– incorporar un cronòmetre per pregunta: temps màxim per pregunta.
– possibilitat de navegació endavant i endarrere pel qüestionari (ara només deixa avançar)
– possibilitat de nombre variable de possibles respostes
– possibilitat de vàries (o cap) respostes correctes possibles
– possibilitat de repetir el qüestionari un nombre màxim de cops.

Més millores:
– Afegir pantalles per a poder entrar nous qüestionaris
– perfils d’usuari (administrador, pot crear qüestionaris; usuari, pot resoldre qüestionaris)
– tenir en compte el curs i que l qüestionari s’adapti als nivell de l’usuari
– selecció de qüestionaris per tòpic, o per codi…
– descarregar d’Internet nous qüestionaris i desar-los en local per a execució off-line
– publicació a un servidor dels resultats obtinguts
– …

En fi. Un munt de possibilitats. De fet, si fem servir programari educatiu veurem un munt d’opcions que es podrien implementar a l’aplicació.

Repositori Github
Tot el codi el podeu trobar al github, a https://github.com/abaranguer/quiz