questionfactory.py 6.87 KB
# We start with an empty QuestionFactory() that will be populated with
# question generators that we can load from YAML files.
# To generate an instance of a question we use the method generate(ref) where
# the argument is the reference of the question we wish to produce.
#
# Example:
#
#   # read everything from question files
#   factory = QuestionFactory()
#   factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to')
#
#   question = factory.generate('some_ref')
#
#   # experiment answering one question and correct it
#   question['answer'] = 42          # insert answer
#   grade = question.correct()       # correct answer

# An instance of an actual question is an object that inherits from Question()
#
# Question          - base class inherited by other classes
# QuestionRadio     - single choice from a list of options
# QuestionCheckbox  - multiple choice, equivalent to multiple true/false
# QuestionText      - line of text compared to a list of acceptable answers
# QuestionTextRegex - line of text matched against a regular expression
# QuestionTextArea  - corrected by an external program
# QuestionInformation - not a question, just a box with content

# base
from os import path
from tools import load_yaml, run_script

import logging




from questions import Question, QuestionRadio, QuestionCheckbox, QuestionText, QuestionTextRegex, QuestionNumericInterval, QuestionTextArea, QuestionInformation


# setup logger for this module
logger = logging.getLogger(__name__)

# ===========================================================================
class QuestionFactoryException(Exception):
    pass



# ===========================================================================
# This class contains a pool of questions generators from which particular
# Question() instances are generated using QuestionsFactory.generate(ref).
# ===========================================================================
class QuestionFactory(dict):
    _types = {
        'radio'     : QuestionRadio,
        'checkbox'  : QuestionCheckbox,
        'text'      : QuestionText,
        'text-regex': QuestionTextRegex,
        'numeric-interval': QuestionNumericInterval,
        'textarea'  : QuestionTextArea,
        # -- informative panels --
        'information': QuestionInformation, 'info': QuestionInformation,
        'warning'   : QuestionInformation,  'warn': QuestionInformation,
        'alert'     : QuestionInformation,
        'success'   : QuestionInformation,
    }


    # -----------------------------------------------------------------------
    def __init__(self):
        super().__init__()

    # -----------------------------------------------------------------------
    # Add single question provided in a dictionary.
    # After this, each question will have at least 'ref' and 'type' keys.
    # -----------------------------------------------------------------------
    def add_question(self, question):
        # if missing defaults to ref='/path/file.yaml:3'
        question.setdefault('ref', f'{question["filename"]}:{question["index"]}')

        if question['ref'] in self:
            logger.error(f'Duplicate reference "{question["ref"]}" replaces the original.')

        question.setdefault('type', 'information')

        self[question['ref']] = question
        logger.debug(f'Added question "{question["ref"]}" to the pool.')

    # -----------------------------------------------------------------------
    # load single YAML questions file
    # -----------------------------------------------------------------------
    def load_file(self, pathfile, questions_dir=''):
        # questions_dir is a base directory
        # pathfile is a path of a file under the questions_dir
        # For example, if
        #    pathfile = 'math/questions.yaml'
        #    questions_dir = '/home/john/questions'
        # then the complete path is
        #    fullpath = '/home/john/questions/math/questions.yaml'
        fullpath = path.normpath(path.join(questions_dir, pathfile))
        (dirname, filename) = path.split(fullpath)

        questions = load_yaml(fullpath, default=[])

        for i, q in enumerate(questions):
            try:
                q.update({
                    'filename': filename,
                    'path': dirname,
                    'index': i              # position in the file, 0 based
                    })
            except AttributeError:
                logger.error(f'Question {pathfile}:{i} is not a dictionary. Skipped!')
            else:
                self.add_question(q)

        logger.info(f'Loaded {len(self)} questions from "{pathfile}".')

    # -----------------------------------------------------------------------
    # load multiple YAML question files
    # -----------------------------------------------------------------------
    def load_files(self, files, questions_dir=''):
        for filename in files:
            self.load_file(filename, questions_dir)

    # -----------------------------------------------------------------------
    # Given a ref returns an instance of a descendent of Question(),
    # i.e. a question object (radio, checkbox, ...).
    # -----------------------------------------------------------------------
    def generate(self, ref):

        # Shallow copy so that script generated questions will not replace
        # the original generators
        try:
            q = self[ref].copy()
        except KeyError:  #FIXME exception type?
            logger.error(f'Can\'t find question "{ref}".')
            raise QuestionFactoryException()

        # If question is of generator type, an external program will be run
        # which will print a valid question in yaml format to stdout. This
        # output is then converted to a dictionary and `q` becomes that dict.
        if q['type'] == 'generator':
            logger.debug('Running script to generate question "{0}".'.format(q['ref']))
            q.setdefault('arg', '') # optional arguments will be sent to stdin
            script = path.normpath(path.join(q['path'], q['script']))
            out = run_script(script=script, stdin=q['arg'])
            try:
                q.update(out)
            except:
                q.update({
                    'type': 'alert',
                    'title': 'Erro interno',
                    'text': 'Ocorreu um erro a gerar esta pergunta.'
                    })
        # The generator was replaced by a question but not yet instantiated

        # Finally we create an instance of Question()
        try:
            qinstance = self._types[q['type']](q)   # instance with correct class
        except KeyError as e:
            logger.error(f'Unknown type "{q["type"]}" in "{q["filename"]}:{q["ref"]}".')
            raise e
        except:
            logger.error(f'Failed to create question "{q["ref"]}" from file "{q["filename"]}".')
            raise
        else:
            logger.debug(f'Generated question "{ref}".')
            return qinstance