questions.py 12.8 KB

import yaml
import random
import re
import subprocess

# Example usage:
#
#   pool = QuestionPool()
#   pool.add_from_file('filename.yaml')
#   pool.add_from_files(['file1.yaml', 'file1.yaml'])
#
#   test = []
#   for q in pool.values():
#       test.append(create_question(q))
#
#   test[0]['answer'] = 42
#   grade = test[0].correct()


# ===========================================================================
class QuestionsPool(dict):
    '''This class contains base questions read from files, but which are
    not ready yet. They have to be instantiated separatly for each student.'''

    #------------------------------------------------------------------------
    def add_from_file(self, filename):
        try:
            with open(filename, 'r') as f:
                questions = yaml.load(f)
        except(FileNotFoundError):
            print('  * Questions file "%s" not found. Ignoring...' % filename)
            return

        # add defaults if missing from sources
        for i, q in enumerate(questions):
            # filename and index (number in the file, 0 based)
            q['filename'] = filename
            q['index'] = i

            # ref  (if missing, add 'filename.yaml:3')
            q['ref'] = str(q.get('ref', filename + ':' + str(i)))

            # type  (default type is just '')
            q['type'] = str(q.get('type', ''))

            # add question to the pool
            self[q['ref']] = q

    #------------------------------------------------------------------------
    def add_from_files(self, file_list):
        for filename in file_list:
            self.add_from_file(filename)


#============================================================================
# Question Factory
# given a dictionary returns a question instance.
def create_question(q):
    '''To create a question, q must be a dictionary with at least the
    following keys defined:
        filename
        ref
        type
    The remaing keys depend on the type of question.
    '''

    types = {
        'information': QuestionInformation,
        'radio'     : QuestionRadio,
        'checkbox'  : QuestionCheckbox,
        'text'      : QuestionText,
        'text_regex': QuestionTextRegex,
        'textarea'  : QuestionTextArea,
        ''          : QuestionInformation,  # default
    }
    # create instance of given type
    try:
        questiontype = types[q['type']]
    except KeyError:
        print('    * unsupported question type in "%s:%s".' % (q['filename'], q['ref']))
        questiontype = Question

    # create question instance and return
    return questiontype(q)

# ===========================================================================
class Question(dict):
    '''
    Classes derived from this base class are meant to instantiate a question
    to a student.
    Instances can shuffle options, or automatically generate questions.
    '''
    pass

# ===========================================================================
class QuestionRadio(Question):
    '''An instance of QuestionRadio will always have the keys:
        type (str)
        text (str)
        options (list of strings)
        shuffle (bool, default True)
        correct (list of floats)
        discount (bool, default True)
        answer (None or an actual answer)
    '''

    #------------------------------------------------------------------------
    def __init__(self, q):
        # create key/values as given in q
        super().__init__(q)

        self['text'] = self.get('text', '')

        # generate an order for the options, e.g. [0,1,2,3,4]
        n = len(self['options'])
        perm = list(range(n))

        # shuffle the order, e.g. [2,1,4,0,3]
        if self.get('shuffle', True):
            self['shuffle'] = True
            random.shuffle(perm)
        else:
            self['shuffle'] = False

        # sort options in the given order
        options = [None] * n  # will contain list with shuffled options
        for i, v in enumerate(self['options']):
            options[perm[i]] = str(v)  # always convert to string
        self['options'] = options

        # default correct option is the first one
        if 'correct' not in self:
            self['correct'] = 0

        # correct can be either an integer with the correct option
        # or a list of degrees of correction 0..1
        # always convert to list, e.g. [0,0,1,0,0]
        if isinstance(self['correct'], int):
            correct = [0.0] * n
            correct[self['correct']] = 1.0
            self['correct'] = correct

        # sort correct in the given order
        correct = [None] * n
        for i, v in enumerate(self['correct']):
            correct[perm[i]] = float(v)
        self['correct'] = correct
        self['discount'] = bool(self.get('discount', True))
        self['answer'] = None


    #------------------------------------------------------------------------
    # can return negative values for wrong answers
    def correct(self):
        if self['answer'] is None:
            x = 0.0      # zero points if no answer given
        else:
            x = self['correct'][int(self['answer'])]
            if self['discount']:
                n = len(self['options'])  # number of options
                x_aver = sum(self['correct']) / n
                x = (x - x_aver) / (1.0 - x_aver)

        self['grade'] = x
        return x

# ===========================================================================
class QuestionCheckbox(Question):
    '''An instance of QuestionCheckbox will always have the keys:
        type (str)
        text (str)
        options (list of strings)
        shuffle (bool, default True)
        correct (list of floats)
        discount (bool, default True)
        answer (None or an actual answer)
    '''

    #------------------------------------------------------------------------
    def __init__(self, q):
        # create key/values as given in q
        super().__init__(q)

        self['text'] = self.get('text', '')

        # generate an order for the options, e.g. [0,1,2,3,4]
        n = len(self['options'])
        perm = list(range(n))

        # shuffle the order, e.g. [2,1,4,0,3]
        if self.get('shuffle', True):
            self['shuffle'] = True
            random.shuffle(perm)
        else:
            self['shuffle'] = False

        # sort options in the given order
        options = [None] * n  # will contain list with shuffled options
        for i, v in enumerate(self['options']):
            options[perm[i]] = str(v)  # always convert to string
        self['options'] = options

        # default is to give zero to all options [0,0,...,0]
        if 'correct' not in self:
            self['correct'] = [0.0] * n

        # sort correct in the given order
        correct = [None] * n
        for i, v in enumerate(self['correct']):
            correct[perm[i]] = float(v)
        self['correct'] = correct

        self['discount'] = bool(self.get('discount', True))

        self['answer'] = None

    #------------------------------------------------------------------------
    # can return negative values for wrong answers
    def correct(self):
        if self['answer'] is None:
            # not answered
            self['grade'] = 0.0
        else:
            # answered
            x = 0.0

            if self['discount']:
                for i, p in enumerate(self['correct']):
                    x += p if str(i) in self['answer'] else -p
                self['grade'] = x / sum(abs(p) for p in self['correct'])
            else:
                for i, p in enumerate(self['correct']):
                    x += p if str(i) in self['answer'] else 0.0
                self['grade'] = x / sum(abs(p) for p in self['correct'])

        return self['grade']

# ===========================================================================
class QuestionText(Question):
    '''An instance of QuestionCheckbox will always have the keys:
        type (str)
        text (str)
        correct (list of str)
        answer (None or an actual answer)
    '''

    #------------------------------------------------------------------------
    def __init__(self, q):
        # create key/values as given in q
        super().__init__(q)

        self['text'] = self.get('text', '')

        # make sure its always a list of possible correct answers
        if isinstance(self['correct'], str):
            self['correct'] = [self['correct']]

        self['answer'] = None

    #------------------------------------------------------------------------
    # can return negative values for wrong answers
    def correct(self):
        if self['answer'] is None:
            # not answered
            self['grade'] = 0.0
        else:
            # answered
            self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0

        return self['grade']

# ===========================================================================
class QuestionTextRegex(Question):
    '''An instance of QuestionCheckbox will always have the keys:
        type (str)
        text (str)
        correct (str with regex)
        answer (None or an actual answer)
    '''

    #------------------------------------------------------------------------
    def __init__(self, q):
        # create key/values as given in q
        super().__init__(q)
        self['text'] = self.get('text', '')
        self['answer'] = None

    #------------------------------------------------------------------------
    # can return negative values for wrong answers
    def correct(self):
        if self['answer'] is None:
            # not answered
            self['grade'] = 0.0
        else:
            # answered
            self['grade'] = 1.0 if re.match(self['correct'], self['answer']) else 0.0

        return self['grade']

# ===========================================================================
class QuestionTextArea(Question):
    '''An instance of QuestionCheckbox will always have the keys:
        type (str)
        text (str)
        correct (str with script to run)
        answer (None or an actual answer)
    '''

    #------------------------------------------------------------------------
    def __init__(self, q):
        # create key/values as given in q
        super().__init__(q)
        self['text'] = self.get('text', '')
        self['answer'] = None

    #------------------------------------------------------------------------
    # can return negative values for wrong answers
    def correct(self):
        if self['answer'] is None:
            # not answered
            self['grade'] = 0.0
        else:
            # answered

            # The correction program expects data from stdin and prints the result to stdout.
            # The result should be a string that can be parsed to a float.
            p = subprocess.Popen([self['correct']], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT)
            try:
                value = p.communicate(input=self['answer'].encode('utf-8'), timeout=5)[0].decode('utf-8')  # esta a dar erro!
            except subprocess.TimeoutExpired:
                p.kill()
                # p.communicate() # FIXME parece que este communicate obriga a ficar ate ao final do processo,  mas nao consigo repetir o comportamento num script simples...
                self['grade'] = 0.0  # student gets a zero if timout occurs

            # In case the correction program returns a non float value, we assume an error
            # has occurred (for instance, invalid input). We just assume its the student's
            # fault and give him Zero.
            try:
                self['grade'] = float(value)
            except (ValueError):
                cherrypy.log.error('While checking answer, process %s returned a non float value: %s' % (self['correct'], value), 'APPLICATION')
                self['grade'] = 0.0

            # Example script to validade answers:
            #     import sys
            #     s = sys.stdin.read()
            #     if s=='Alibaba':
            #         print(1.0)
            #     else:
            #         print(0.0)
            #     exit(0)

        return self['grade']

# ===========================================================================
class QuestionInformation(Question):
    '''An instance of QuestionCheckbox will always have the keys:
        type (str)
        text (str)
        correct (str with regex)
        answer (None or an actual answer)
    '''
    #------------------------------------------------------------------------
    def __init__(self, q):
        # create key/values as given in q
        super().__init__(q)
        self['text'] = self.get('text', '')
        self['answer'] = None
        self['points'] = 0.0

    #------------------------------------------------------------------------
    # can return negative values for wrong answers
    def correct(self):
        self['grade'] = 1.0  # always "correct" but points should be zero!
        return self['grade']