Commit d9224bb6621ce6a511c6307b216c4e00827da418

Authored by Miguel Barão
1 parent 6cdd62d7
Exists in master and in 1 other branch dev

- missing questionfactory.py from last commit...

Showing 1 changed file with 169 additions and 0 deletions   Show diff stats
questionfactory.py 0 → 100644
... ... @@ -0,0 +1,169 @@
  1 +# We start with an empty QuestionFactory() that will be populated with
  2 +# question generators that we can load from YAML files.
  3 +# To generate an instance of a question we use the method generate(ref) where
  4 +# the argument is the reference of the question we wish to produce.
  5 +#
  6 +# Example:
  7 +#
  8 +# # read everything from question files
  9 +# factory = QuestionFactory()
  10 +# factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to')
  11 +#
  12 +# question = factory.generate('some_ref')
  13 +#
  14 +# # experiment answering one question and correct it
  15 +# question['answer'] = 42 # insert answer
  16 +# grade = question.correct() # correct answer
  17 +
  18 +# An instance of an actual question is an object that inherits from Question()
  19 +#
  20 +# Question - base class inherited by other classes
  21 +# QuestionRadio - single choice from a list of options
  22 +# QuestionCheckbox - multiple choice, equivalent to multiple true/false
  23 +# QuestionText - line of text compared to a list of acceptable answers
  24 +# QuestionTextRegex - line of text matched against a regular expression
  25 +# QuestionTextArea - corrected by an external program
  26 +# QuestionInformation - not a question, just a box with content
  27 +
  28 +# base
  29 +from os import path
  30 +from tools import load_yaml, run_script
  31 +
  32 +import logging
  33 +
  34 +
  35 +
  36 +
  37 +from questions import Question, QuestionRadio, QuestionCheckbox, QuestionText, QuestionTextRegex, QuestionNumericInterval, QuestionTextArea, QuestionInformation
  38 +
  39 +
  40 +# setup logger for this module
  41 +logger = logging.getLogger(__name__)
  42 +
  43 +# ===========================================================================
  44 +class QuestionFactoryException(Exception):
  45 + pass
  46 +
  47 +
  48 +
  49 +# ===========================================================================
  50 +# This class contains a pool of questions generators from which particular
  51 +# Question() instances are generated using QuestionsFactory.generate(ref).
  52 +# ===========================================================================
  53 +class QuestionFactory(dict):
  54 + _types = {
  55 + 'radio' : QuestionRadio,
  56 + 'checkbox' : QuestionCheckbox,
  57 + 'text' : QuestionText,
  58 + 'text-regex': QuestionTextRegex,
  59 + 'numeric-interval': QuestionNumericInterval,
  60 + 'textarea' : QuestionTextArea,
  61 + # -- informative panels --
  62 + 'information': QuestionInformation, 'info': QuestionInformation,
  63 + 'warning' : QuestionInformation, 'warn': QuestionInformation,
  64 + 'alert' : QuestionInformation,
  65 + 'success' : QuestionInformation,
  66 + }
  67 +
  68 +
  69 + # -----------------------------------------------------------------------
  70 + def __init__(self):
  71 + super().__init__()
  72 +
  73 + # -----------------------------------------------------------------------
  74 + # Add single question provided in a dictionary.
  75 + # After this, each question will have at least 'ref' and 'type' keys.
  76 + # -----------------------------------------------------------------------
  77 + def add_question(self, question):
  78 + # if missing defaults to ref='/path/file.yaml:3'
  79 + question.setdefault('ref', f'{question["filename"]}:{question["index"]}')
  80 +
  81 + if question['ref'] in self:
  82 + logger.error(f'Duplicate reference "{question["ref"]}" replaces the original.')
  83 +
  84 + question.setdefault('type', 'information')
  85 +
  86 + self[question['ref']] = question
  87 + logger.debug(f'Added question "{question["ref"]}" to the pool.')
  88 +
  89 + # -----------------------------------------------------------------------
  90 + # load single YAML questions file
  91 + # -----------------------------------------------------------------------
  92 + def load_file(self, pathfile, questions_dir=''):
  93 + # questions_dir is a base directory
  94 + # pathfile is a path of a file under the questions_dir
  95 + # For example, if
  96 + # pathfile = 'math/questions.yaml'
  97 + # questions_dir = '/home/john/questions'
  98 + # then the complete path is
  99 + # fullpath = '/home/john/questions/math/questions.yaml'
  100 + fullpath = path.normpath(path.join(questions_dir, pathfile))
  101 + (dirname, filename) = path.split(fullpath)
  102 +
  103 + questions = load_yaml(fullpath, default=[])
  104 +
  105 + for i, q in enumerate(questions):
  106 + try:
  107 + q.update({
  108 + 'filename': filename,
  109 + 'path': dirname,
  110 + 'index': i # position in the file, 0 based
  111 + })
  112 + except AttributeError:
  113 + logger.error(f'Question {pathfile}:{i} is not a dictionary. Skipped!')
  114 + else:
  115 + self.add_question(q)
  116 +
  117 + logger.info(f'Loaded {len(self)} questions from "{pathfile}".')
  118 +
  119 + # -----------------------------------------------------------------------
  120 + # load multiple YAML question files
  121 + # -----------------------------------------------------------------------
  122 + def load_files(self, files, questions_dir=''):
  123 + for filename in files:
  124 + self.load_file(filename, questions_dir)
  125 +
  126 + # -----------------------------------------------------------------------
  127 + # Given a ref returns an instance of a descendent of Question(),
  128 + # i.e. a question object (radio, checkbox, ...).
  129 + # -----------------------------------------------------------------------
  130 + def generate(self, ref):
  131 +
  132 + # Shallow copy so that script generated questions will not replace
  133 + # the original generators
  134 + try:
  135 + q = self[ref].copy()
  136 + except KeyError: #FIXME exception type?
  137 + logger.error(f'Can\'t find question "{ref}".')
  138 + raise QuestionFactoryException()
  139 +
  140 + # If question is of generator type, an external program will be run
  141 + # which will print a valid question in yaml format to stdout. This
  142 + # output is then converted to a dictionary and `q` becomes that dict.
  143 + if q['type'] == 'generator':
  144 + logger.debug('Running script to generate question "{0}".'.format(q['ref']))
  145 + q.setdefault('arg', '') # optional arguments will be sent to stdin
  146 + script = path.normpath(path.join(q['path'], q['script']))
  147 + out = run_script(script=script, stdin=q['arg'])
  148 + try:
  149 + q.update(out)
  150 + except:
  151 + q.update({
  152 + 'type': 'alert',
  153 + 'title': 'Erro interno',
  154 + 'text': 'Ocorreu um erro a gerar esta pergunta.'
  155 + })
  156 + # The generator was replaced by a question but not yet instantiated
  157 +
  158 + # Finally we create an instance of Question()
  159 + try:
  160 + qinstance = self._types[q['type']](q) # instance with correct class
  161 + except KeyError as e:
  162 + logger.error(f'Unknown type "{q["type"]}" in "{q["filename"]}:{q["ref"]}".')
  163 + raise e
  164 + except:
  165 + logger.error(f'Failed to create question "{q["ref"]}" from file "{q["filename"]}".')
  166 + raise
  167 + else:
  168 + logger.debug(f'Generated question "{ref}".')
  169 + return qinstance
... ...