Commit d9224bb6621ce6a511c6307b216c4e00827da418
1 parent
6cdd62d7
Exists in
master
and in
1 other branch
- missing questionfactory.py from last commit...
Showing
1 changed file
with
169 additions
and
0 deletions
Show diff stats
| ... | ... | @@ -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 | ... | ... |