Commit 4f960e53bb56d5a8c499c0fa8fb0ed54decff53c
1 parent
adb8522e
Exists in
master
and in
1 other branch
split test.py into test.py and testfactory.py
Showing
4 changed files
with
303 additions
and
300 deletions
Show diff stats
perguntations/app.py
| ... | ... | @@ -20,7 +20,7 @@ from sqlalchemy.orm import sessionmaker |
| 20 | 20 | # this project |
| 21 | 21 | from perguntations.models import Student, Test, Question |
| 22 | 22 | from perguntations.tools import load_yaml |
| 23 | -from perguntations.test import TestFactory, TestFactoryException | |
| 23 | +from perguntations.testfactory import TestFactory, TestFactoryException | |
| 24 | 24 | |
| 25 | 25 | logger = logging.getLogger(__name__) |
| 26 | 26 | |
| ... | ... | @@ -182,22 +182,16 @@ class App(): |
| 182 | 182 | testconf.update(conf) |
| 183 | 183 | |
| 184 | 184 | # start test factory |
| 185 | - logger.info('Making test factory...') | |
| 185 | + logger.info('Running test factory...') | |
| 186 | 186 | try: |
| 187 | 187 | self.testfactory = TestFactory(testconf) |
| 188 | 188 | except TestFactoryException as exc: |
| 189 | 189 | logger.critical(exc) |
| 190 | 190 | raise AppException('Failed to create test factory!') from exc |
| 191 | 191 | |
| 192 | - logger.info('Test factory ready. No errors found.') | |
| 193 | - | |
| 194 | 192 | # ------------------------------------------------------------------------ |
| 195 | 193 | def _pregenerate_tests(self, num): |
| 196 | 194 | event_loop = asyncio.get_event_loop() |
| 197 | - # for _ in range(num): | |
| 198 | - # test = event_loop.run_until_complete(self.testfactory.generate()) | |
| 199 | - # self.pregenerated_tests.append(test) | |
| 200 | - | |
| 201 | 195 | self.pregenerated_tests += [ |
| 202 | 196 | event_loop.run_until_complete(self.testfactory.generate()) |
| 203 | 197 | for _ in range(num)] | ... | ... |
perguntations/main.py
| ... | ... | @@ -99,13 +99,12 @@ def get_logger_config(debug=False): |
| 99 | 99 | }, |
| 100 | 100 | }, |
| 101 | 101 | } |
| 102 | - default_config['loggers'].update({ | |
| 103 | - f'{APP_NAME}.{module}': { | |
| 104 | - 'handlers': ['default'], | |
| 105 | - 'level': level, | |
| 106 | - 'propagate': False, | |
| 107 | - } for module in ['app', 'models', 'factory', 'questions', | |
| 108 | - 'test', 'tools']}) | |
| 102 | + | |
| 103 | + modules = ['app', 'models', 'questions', 'test', 'testfactory', 'tools'] | |
| 104 | + logger = {'handlers': ['default'], 'level': level, 'propagate': False} | |
| 105 | + | |
| 106 | + default_config['loggers'].update({f'{APP_NAME}.{module}': logger | |
| 107 | + for module in modules}) | |
| 109 | 108 | |
| 110 | 109 | return load_yaml(config_file, default=default_config) |
| 111 | 110 | ... | ... |
perguntations/test.py
| 1 | 1 | ''' |
| 2 | -TestFactory - generates tests for students | |
| 3 | 2 | Test - instances of this class are individual tests |
| 4 | 3 | ''' |
| 5 | 4 | |
| 6 | - | |
| 7 | 5 | # python standard library |
| 8 | -from os import path | |
| 9 | -import random | |
| 10 | 6 | from datetime import datetime |
| 11 | 7 | import logging |
| 12 | -import re | |
| 13 | -from typing import Any, Dict | |
| 14 | - | |
| 15 | -# this project | |
| 16 | -from perguntations.questions import QFactory, QuestionException | |
| 17 | -from perguntations.tools import load_yaml | |
| 18 | 8 | |
| 19 | 9 | # Logger configuration |
| 20 | 10 | logger = logging.getLogger(__name__) |
| 21 | 11 | |
| 22 | 12 | |
| 23 | 13 | # ============================================================================ |
| 24 | -class TestFactoryException(Exception): | |
| 25 | - '''exception raised in this module''' | |
| 26 | - | |
| 27 | - | |
| 28 | -# ============================================================================ | |
| 29 | -class TestFactory(dict): | |
| 30 | - ''' | |
| 31 | - Each instance of TestFactory() is a test generator. | |
| 32 | - For example, if we want to serve two different tests, then we need two | |
| 33 | - instances of TestFactory(), one for each test. | |
| 34 | - ''' | |
| 35 | - | |
| 36 | - # ------------------------------------------------------------------------ | |
| 37 | - def __init__(self, conf: Dict[str, Any]) -> None: | |
| 38 | - ''' | |
| 39 | - Loads configuration from yaml file, then overrides some configurations | |
| 40 | - using the conf argument. | |
| 41 | - Base questions are added to a pool of questions factories. | |
| 42 | - ''' | |
| 43 | - | |
| 44 | - # --- set test defaults and then use given configuration | |
| 45 | - super().__init__({ # defaults | |
| 46 | - 'title': '', | |
| 47 | - 'show_points': True, | |
| 48 | - 'scale': None, # or [0, 20] | |
| 49 | - 'duration': 0, # 0=infinite | |
| 50 | - 'autosubmit': False, | |
| 51 | - 'debug': False, | |
| 52 | - 'show_ref': False, | |
| 53 | - }) | |
| 54 | - self.update(conf) | |
| 55 | - | |
| 56 | - # --- perform sanity checks and normalize the test questions | |
| 57 | - self.sanity_checks() | |
| 58 | - logger.info('Sanity checks PASSED.') | |
| 59 | - | |
| 60 | - # --- find refs of all questions used in the test | |
| 61 | - qrefs = {r for qq in self['questions'] for r in qq['ref']} | |
| 62 | - logger.info('Declared %d questions (each test uses %d).', | |
| 63 | - len(qrefs), len(self["questions"])) | |
| 64 | - | |
| 65 | - # --- for review, we are done. no factories needed | |
| 66 | - if self['review']: | |
| 67 | - logger.info('Review mode. No questions loaded. No factories.') | |
| 68 | - return | |
| 69 | - | |
| 70 | - # --- load and build question factories | |
| 71 | - self.question_factory = {} | |
| 72 | - | |
| 73 | - counter = 1 | |
| 74 | - for file in self["files"]: | |
| 75 | - fullpath = path.normpath(path.join(self["questions_dir"], file)) | |
| 76 | - (dirname, filename) = path.split(fullpath) | |
| 77 | - | |
| 78 | - logger.info('Loading "%s"...', fullpath) | |
| 79 | - questions = load_yaml(fullpath) # , default=[]) | |
| 80 | - | |
| 81 | - for i, question in enumerate(questions): | |
| 82 | - # make sure every question in the file is a dictionary | |
| 83 | - if not isinstance(question, dict): | |
| 84 | - msg = f'Question {i} in {file} is not a dictionary' | |
| 85 | - raise TestFactoryException(msg) | |
| 86 | - | |
| 87 | - # check if ref is missing, then set to '/path/file.yaml:3' | |
| 88 | - if 'ref' not in question: | |
| 89 | - question['ref'] = f'{file}:{i:04}' | |
| 90 | - logger.warning('Missing ref set to "%s"', question["ref"]) | |
| 91 | - | |
| 92 | - # check for duplicate refs | |
| 93 | - if question['ref'] in self.question_factory: | |
| 94 | - other = self.question_factory[question['ref']] | |
| 95 | - otherfile = path.join(other.question['path'], | |
| 96 | - other.question['filename']) | |
| 97 | - msg = (f'Duplicate reference "{question["ref"]}" in files ' | |
| 98 | - f'"{otherfile}" and "{fullpath}".') | |
| 99 | - raise TestFactoryException(msg) | |
| 100 | - | |
| 101 | - # make factory only for the questions used in the test | |
| 102 | - if question['ref'] in qrefs: | |
| 103 | - question.setdefault('type', 'information') | |
| 104 | - question.update({ | |
| 105 | - 'filename': filename, | |
| 106 | - 'path': dirname, | |
| 107 | - 'index': i # position in the file, 0 based | |
| 108 | - }) | |
| 109 | - | |
| 110 | - self.question_factory[question['ref']] = QFactory(question) | |
| 111 | - | |
| 112 | - # check if all the questions can be correctly generated | |
| 113 | - try: | |
| 114 | - self.question_factory[question['ref']].generate() | |
| 115 | - except Exception as exc: | |
| 116 | - msg = f'Failed to generate "{question["ref"]}"' | |
| 117 | - raise TestFactoryException(msg) from exc | |
| 118 | - else: | |
| 119 | - logger.info('%4d. "%s" Ok.', counter, question["ref"]) | |
| 120 | - counter += 1 | |
| 121 | - | |
| 122 | - qmissing = qrefs.difference(set(self.question_factory.keys())) | |
| 123 | - if qmissing: | |
| 124 | - raise TestFactoryException(f'Could not find questions {qmissing}.') | |
| 125 | - | |
| 126 | - # ------------------------------------------------------------------------ | |
| 127 | - def check_test_ref(self) -> None: | |
| 128 | - '''Test must have a `ref`''' | |
| 129 | - if 'ref' not in self: | |
| 130 | - raise TestFactoryException('Missing "ref" in configuration!') | |
| 131 | - if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']): | |
| 132 | - raise TestFactoryException('Test "ref" can only contain the ' | |
| 133 | - 'characters a-zA-Z0-9_-') | |
| 134 | - | |
| 135 | - def check_missing_database(self) -> None: | |
| 136 | - '''Test must have a database''' | |
| 137 | - if 'database' not in self: | |
| 138 | - raise TestFactoryException('Missing "database" in configuration') | |
| 139 | - if not path.isfile(path.expanduser(self['database'])): | |
| 140 | - msg = f'Database "{self["database"]}" not found!' | |
| 141 | - raise TestFactoryException(msg) | |
| 142 | - | |
| 143 | - def check_missing_answers_directory(self) -> None: | |
| 144 | - '''Test must have a answers directory''' | |
| 145 | - if 'answers_dir' not in self: | |
| 146 | - msg = 'Missing "answers_dir" in configuration' | |
| 147 | - raise TestFactoryException(msg) | |
| 148 | - | |
| 149 | - def check_answers_directory_writable(self) -> None: | |
| 150 | - '''Answers directory must be writable''' | |
| 151 | - testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') | |
| 152 | - try: | |
| 153 | - with open(testfile, 'w') as file: | |
| 154 | - file.write('You can safely remove this file.') | |
| 155 | - except OSError as exc: | |
| 156 | - msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | |
| 157 | - raise TestFactoryException(msg) from exc | |
| 158 | - | |
| 159 | - def check_questions_directory(self) -> None: | |
| 160 | - '''Check if questions directory is missing or not accessible.''' | |
| 161 | - if 'questions_dir' not in self: | |
| 162 | - logger.warning('Missing "questions_dir". Using "%s"', | |
| 163 | - path.abspath(path.curdir)) | |
| 164 | - self['questions_dir'] = path.curdir | |
| 165 | - elif not path.isdir(path.expanduser(self['questions_dir'])): | |
| 166 | - raise TestFactoryException(f'Can\'t find questions directory ' | |
| 167 | - f'"{self["questions_dir"]}"') | |
| 168 | - | |
| 169 | - def check_import_files(self) -> None: | |
| 170 | - '''Check if there are files to import (with questions)''' | |
| 171 | - if 'files' not in self: | |
| 172 | - msg = ('Missing "files" in configuration with the list of ' | |
| 173 | - 'question files to import!') | |
| 174 | - raise TestFactoryException(msg) | |
| 175 | - | |
| 176 | - if isinstance(self['files'], str): | |
| 177 | - self['files'] = [self['files']] | |
| 178 | - | |
| 179 | - def check_question_list(self) -> None: | |
| 180 | - '''normalize question list''' | |
| 181 | - if 'questions' not in self: | |
| 182 | - raise TestFactoryException('Missing "questions" in configuration') | |
| 183 | - | |
| 184 | - for i, question in enumerate(self['questions']): | |
| 185 | - # normalize question to a dict and ref to a list of references | |
| 186 | - if isinstance(question, str): # e.g., - some_ref | |
| 187 | - question = {'ref': [question]} # becomes - ref: [some_ref] | |
| 188 | - elif isinstance(question, dict) and isinstance(question['ref'], str): | |
| 189 | - question['ref'] = [question['ref']] | |
| 190 | - elif isinstance(question, list): | |
| 191 | - question = {'ref': [str(a) for a in question]} | |
| 192 | - | |
| 193 | - self['questions'][i] = question | |
| 194 | - | |
| 195 | - def check_missing_title(self) -> None: | |
| 196 | - '''Warns if title is missing''' | |
| 197 | - if not self['title']: | |
| 198 | - logger.warning('Title is undefined!') | |
| 199 | - | |
| 200 | - def check_grade_scaling(self) -> None: | |
| 201 | - '''Just informs the scale limits''' | |
| 202 | - if 'scale_points' in self: | |
| 203 | - msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, ' | |
| 204 | - 'scale_max were replaced by "scale: [min, max]".') | |
| 205 | - logger.warning(msg) | |
| 206 | - self['scale'] = [self['scale_min'], self['scale_max']] | |
| 207 | - | |
| 208 | - | |
| 209 | - # ------------------------------------------------------------------------ | |
| 210 | - def sanity_checks(self) -> None: | |
| 211 | - ''' | |
| 212 | - Checks for valid keys and sets default values. | |
| 213 | - Also checks if some files and directories exist | |
| 214 | - ''' | |
| 215 | - self.check_test_ref() | |
| 216 | - self.check_missing_database() | |
| 217 | - self.check_missing_answers_directory() | |
| 218 | - self.check_answers_directory_writable() | |
| 219 | - self.check_questions_directory() | |
| 220 | - self.check_import_files() | |
| 221 | - self.check_question_list() | |
| 222 | - self.check_missing_title() | |
| 223 | - self.check_grade_scaling() | |
| 224 | - | |
| 225 | - # ------------------------------------------------------------------------ | |
| 226 | - async def generate(self): | |
| 227 | - ''' | |
| 228 | - Given a dictionary with a student dict {'name':'john', 'number': 123} | |
| 229 | - returns instance of Test() for that particular student | |
| 230 | - ''' | |
| 231 | - | |
| 232 | - # make list of questions | |
| 233 | - questions = [] | |
| 234 | - qnum = 1 # track question number | |
| 235 | - nerr = 0 # count errors during questions generation | |
| 236 | - | |
| 237 | - for qlist in self['questions']: | |
| 238 | - # choose list of question variants | |
| 239 | - choose = qlist.get('choose', 1) | |
| 240 | - qrefs = random.sample(qlist['ref'], k=choose) | |
| 241 | - | |
| 242 | - for qref in qrefs: | |
| 243 | - # generate instance of question | |
| 244 | - try: | |
| 245 | - question = await self.question_factory[qref].gen_async() | |
| 246 | - except QuestionException: | |
| 247 | - logger.error('Can\'t generate question "%s". Skipping.', qref) | |
| 248 | - nerr += 1 | |
| 249 | - continue | |
| 250 | - | |
| 251 | - # some defaults | |
| 252 | - if question['type'] in ('information', 'success', 'warning', | |
| 253 | - 'alert'): | |
| 254 | - question['points'] = qlist.get('points', 0.0) | |
| 255 | - else: | |
| 256 | - question['points'] = qlist.get('points', 1.0) | |
| 257 | - question['number'] = qnum # counter for non informative panels | |
| 258 | - qnum += 1 | |
| 259 | - | |
| 260 | - questions.append(question) | |
| 261 | - | |
| 262 | - # setup scale | |
| 263 | - total_points = sum(q['points'] for q in questions) | |
| 264 | - | |
| 265 | - if total_points > 0: | |
| 266 | - # normalize question points to scale | |
| 267 | - if self['scale'] is not None: | |
| 268 | - scale_min, scale_max = self['scale'] | |
| 269 | - for question in questions: | |
| 270 | - question['points'] *= (scale_max - scale_min) / total_points | |
| 271 | - else: | |
| 272 | - self['scale'] = [0, total_points] | |
| 273 | - else: | |
| 274 | - logger.warning('Total points is **ZERO**.') | |
| 275 | - if self['scale'] is None: | |
| 276 | - self['scale'] = [0, 20] # default | |
| 277 | - | |
| 278 | - if nerr > 0: | |
| 279 | - logger.error('%s errors found!', nerr) | |
| 280 | - | |
| 281 | - # copy these from the test configuratoin to each test instance | |
| 282 | - inherit = {'ref', 'title', 'database', 'answers_dir', | |
| 283 | - 'questions_dir', 'files', | |
| 284 | - 'duration', 'autosubmit', | |
| 285 | - 'scale', 'show_points', | |
| 286 | - 'show_ref', 'debug', } | |
| 287 | - # NOT INCLUDED: testfile, allow_all, review | |
| 288 | - | |
| 289 | - return Test({'questions': questions, **{k:self[k] for k in inherit}}) | |
| 290 | - | |
| 291 | - # ------------------------------------------------------------------------ | |
| 292 | - def __repr__(self): | |
| 293 | - testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) | |
| 294 | - return '{\n' + testsettings + '\n}' | |
| 295 | - | |
| 296 | - | |
| 297 | -# ============================================================================ | |
| 298 | 14 | class Test(dict): |
| 299 | 15 | ''' |
| 300 | 16 | Each instance Test() is a concrete test of a single student. |
| ... | ... | @@ -335,7 +51,6 @@ class Test(dict): |
| 335 | 51 | ''' |
| 336 | 52 | for ref, ans in answers_dict.items(): |
| 337 | 53 | self['questions'][ref].set_answer(ans) |
| 338 | - # self['questions'][ref]['answer'] = ans | |
| 339 | 54 | |
| 340 | 55 | # ------------------------------------------------------------------------ |
| 341 | 56 | async def correct(self) -> float: | ... | ... |
| ... | ... | @@ -0,0 +1,295 @@ |
| 1 | +''' | |
| 2 | +TestFactory - generates tests for students | |
| 3 | +''' | |
| 4 | + | |
| 5 | +# python standard library | |
| 6 | +from os import path | |
| 7 | +import random | |
| 8 | +import logging | |
| 9 | +import re | |
| 10 | +from typing import Any, Dict | |
| 11 | + | |
| 12 | +# this project | |
| 13 | +from perguntations.questions import QFactory, QuestionException | |
| 14 | +from perguntations.test import Test | |
| 15 | +from perguntations.tools import load_yaml | |
| 16 | + | |
| 17 | +# Logger configuration | |
| 18 | +logger = logging.getLogger(__name__) | |
| 19 | + | |
| 20 | + | |
| 21 | +# ============================================================================ | |
| 22 | +class TestFactoryException(Exception): | |
| 23 | + '''exception raised in this module''' | |
| 24 | + | |
| 25 | + | |
| 26 | +# ============================================================================ | |
| 27 | +class TestFactory(dict): | |
| 28 | + ''' | |
| 29 | + Each instance of TestFactory() is a test generator. | |
| 30 | + For example, if we want to serve two different tests, then we need two | |
| 31 | + instances of TestFactory(), one for each test. | |
| 32 | + ''' | |
| 33 | + | |
| 34 | + # ------------------------------------------------------------------------ | |
| 35 | + def __init__(self, conf: Dict[str, Any]) -> None: | |
| 36 | + ''' | |
| 37 | + Loads configuration from yaml file, then overrides some configurations | |
| 38 | + using the conf argument. | |
| 39 | + Base questions are added to a pool of questions factories. | |
| 40 | + ''' | |
| 41 | + | |
| 42 | + # --- set test defaults and then use given configuration | |
| 43 | + super().__init__({ # defaults | |
| 44 | + 'title': '', | |
| 45 | + 'show_points': True, | |
| 46 | + 'scale': None, # or [0, 20] | |
| 47 | + 'duration': 0, # 0=infinite | |
| 48 | + 'autosubmit': False, | |
| 49 | + 'debug': False, | |
| 50 | + 'show_ref': False, | |
| 51 | + }) | |
| 52 | + self.update(conf) | |
| 53 | + | |
| 54 | + # --- for review, we are done. no factories needed | |
| 55 | + if self['review']: | |
| 56 | + logger.info('Review mode. No questions loaded. No factories.') | |
| 57 | + return | |
| 58 | + | |
| 59 | + # --- perform sanity checks and normalize the test questions | |
| 60 | + self.sanity_checks() | |
| 61 | + logger.info('Sanity checks PASSED.') | |
| 62 | + | |
| 63 | + # --- find refs of all questions used in the test | |
| 64 | + qrefs = {r for qq in self['questions'] for r in qq['ref']} | |
| 65 | + logger.info('Declared %d questions (each test uses %d).', | |
| 66 | + len(qrefs), len(self["questions"])) | |
| 67 | + | |
| 68 | + # --- load and build question factories | |
| 69 | + self.question_factory = {} | |
| 70 | + | |
| 71 | + counter = 1 | |
| 72 | + for file in self["files"]: | |
| 73 | + fullpath = path.normpath(path.join(self["questions_dir"], file)) | |
| 74 | + (dirname, filename) = path.split(fullpath) | |
| 75 | + | |
| 76 | + logger.info('Loading "%s"...', fullpath) | |
| 77 | + questions = load_yaml(fullpath) # , default=[]) | |
| 78 | + | |
| 79 | + for i, question in enumerate(questions): | |
| 80 | + # make sure every question in the file is a dictionary | |
| 81 | + if not isinstance(question, dict): | |
| 82 | + msg = f'Question {i} in {file} is not a dictionary' | |
| 83 | + raise TestFactoryException(msg) | |
| 84 | + | |
| 85 | + # check if ref is missing, then set to '/path/file.yaml:3' | |
| 86 | + if 'ref' not in question: | |
| 87 | + question['ref'] = f'{file}:{i:04}' | |
| 88 | + logger.warning('Missing ref set to "%s"', question["ref"]) | |
| 89 | + | |
| 90 | + # check for duplicate refs | |
| 91 | + if question['ref'] in self.question_factory: | |
| 92 | + other = self.question_factory[question['ref']] | |
| 93 | + otherfile = path.join(other.question['path'], | |
| 94 | + other.question['filename']) | |
| 95 | + msg = (f'Duplicate reference "{question["ref"]}" in files ' | |
| 96 | + f'"{otherfile}" and "{fullpath}".') | |
| 97 | + raise TestFactoryException(msg) | |
| 98 | + | |
| 99 | + # make factory only for the questions used in the test | |
| 100 | + if question['ref'] in qrefs: | |
| 101 | + question.setdefault('type', 'information') | |
| 102 | + question.update({ | |
| 103 | + 'filename': filename, | |
| 104 | + 'path': dirname, | |
| 105 | + 'index': i # position in the file, 0 based | |
| 106 | + }) | |
| 107 | + | |
| 108 | + self.question_factory[question['ref']] = QFactory(question) | |
| 109 | + | |
| 110 | + # check if all the questions can be correctly generated | |
| 111 | + try: | |
| 112 | + self.question_factory[question['ref']].generate() | |
| 113 | + except Exception as exc: | |
| 114 | + msg = f'Failed to generate "{question["ref"]}"' | |
| 115 | + raise TestFactoryException(msg) from exc | |
| 116 | + else: | |
| 117 | + logger.info('%4d. "%s" Ok.', counter, question["ref"]) | |
| 118 | + counter += 1 | |
| 119 | + | |
| 120 | + qmissing = qrefs.difference(set(self.question_factory.keys())) | |
| 121 | + if qmissing: | |
| 122 | + raise TestFactoryException(f'Could not find questions {qmissing}.') | |
| 123 | + | |
| 124 | + logger.info('Test factory ready. No errors found.') | |
| 125 | + | |
| 126 | + | |
| 127 | + # ------------------------------------------------------------------------ | |
| 128 | + def check_test_ref(self) -> None: | |
| 129 | + '''Test must have a `ref`''' | |
| 130 | + if 'ref' not in self: | |
| 131 | + raise TestFactoryException('Missing "ref" in configuration!') | |
| 132 | + if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']): | |
| 133 | + raise TestFactoryException('Test "ref" can only contain the ' | |
| 134 | + 'characters a-zA-Z0-9_-') | |
| 135 | + | |
| 136 | + def check_missing_database(self) -> None: | |
| 137 | + '''Test must have a database''' | |
| 138 | + if 'database' not in self: | |
| 139 | + raise TestFactoryException('Missing "database" in configuration') | |
| 140 | + if not path.isfile(path.expanduser(self['database'])): | |
| 141 | + msg = f'Database "{self["database"]}" not found!' | |
| 142 | + raise TestFactoryException(msg) | |
| 143 | + | |
| 144 | + def check_missing_answers_directory(self) -> None: | |
| 145 | + '''Test must have a answers directory''' | |
| 146 | + if 'answers_dir' not in self: | |
| 147 | + msg = 'Missing "answers_dir" in configuration' | |
| 148 | + raise TestFactoryException(msg) | |
| 149 | + | |
| 150 | + def check_answers_directory_writable(self) -> None: | |
| 151 | + '''Answers directory must be writable''' | |
| 152 | + testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') | |
| 153 | + try: | |
| 154 | + with open(testfile, 'w') as file: | |
| 155 | + file.write('You can safely remove this file.') | |
| 156 | + except OSError as exc: | |
| 157 | + msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | |
| 158 | + raise TestFactoryException(msg) from exc | |
| 159 | + | |
| 160 | + def check_questions_directory(self) -> None: | |
| 161 | + '''Check if questions directory is missing or not accessible.''' | |
| 162 | + if 'questions_dir' not in self: | |
| 163 | + logger.warning('Missing "questions_dir". Using "%s"', | |
| 164 | + path.abspath(path.curdir)) | |
| 165 | + self['questions_dir'] = path.curdir | |
| 166 | + elif not path.isdir(path.expanduser(self['questions_dir'])): | |
| 167 | + raise TestFactoryException(f'Can\'t find questions directory ' | |
| 168 | + f'"{self["questions_dir"]}"') | |
| 169 | + | |
| 170 | + def check_import_files(self) -> None: | |
| 171 | + '''Check if there are files to import (with questions)''' | |
| 172 | + if 'files' not in self: | |
| 173 | + msg = ('Missing "files" in configuration with the list of ' | |
| 174 | + 'question files to import!') | |
| 175 | + raise TestFactoryException(msg) | |
| 176 | + | |
| 177 | + if isinstance(self['files'], str): | |
| 178 | + self['files'] = [self['files']] | |
| 179 | + | |
| 180 | + def check_question_list(self) -> None: | |
| 181 | + '''normalize question list''' | |
| 182 | + if 'questions' not in self: | |
| 183 | + raise TestFactoryException('Missing "questions" in configuration') | |
| 184 | + | |
| 185 | + for i, question in enumerate(self['questions']): | |
| 186 | + # normalize question to a dict and ref to a list of references | |
| 187 | + if isinstance(question, str): # e.g., - some_ref | |
| 188 | + question = {'ref': [question]} # becomes - ref: [some_ref] | |
| 189 | + elif isinstance(question, dict) and isinstance(question['ref'], str): | |
| 190 | + question['ref'] = [question['ref']] | |
| 191 | + elif isinstance(question, list): | |
| 192 | + question = {'ref': [str(a) for a in question]} | |
| 193 | + | |
| 194 | + self['questions'][i] = question | |
| 195 | + | |
| 196 | + def check_missing_title(self) -> None: | |
| 197 | + '''Warns if title is missing''' | |
| 198 | + if not self['title']: | |
| 199 | + logger.warning('Title is undefined!') | |
| 200 | + | |
| 201 | + def check_grade_scaling(self) -> None: | |
| 202 | + '''Just informs the scale limits''' | |
| 203 | + if 'scale_points' in self: | |
| 204 | + msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, ' | |
| 205 | + 'scale_max were replaced by "scale: [min, max]".') | |
| 206 | + logger.warning(msg) | |
| 207 | + self['scale'] = [self['scale_min'], self['scale_max']] | |
| 208 | + | |
| 209 | + | |
| 210 | + # ------------------------------------------------------------------------ | |
| 211 | + def sanity_checks(self) -> None: | |
| 212 | + ''' | |
| 213 | + Checks for valid keys and sets default values. | |
| 214 | + Also checks if some files and directories exist | |
| 215 | + ''' | |
| 216 | + self.check_test_ref() | |
| 217 | + self.check_missing_database() | |
| 218 | + self.check_missing_answers_directory() | |
| 219 | + self.check_answers_directory_writable() | |
| 220 | + self.check_questions_directory() | |
| 221 | + self.check_import_files() | |
| 222 | + self.check_question_list() | |
| 223 | + self.check_missing_title() | |
| 224 | + self.check_grade_scaling() | |
| 225 | + | |
| 226 | + # ------------------------------------------------------------------------ | |
| 227 | + async def generate(self): | |
| 228 | + ''' | |
| 229 | + Given a dictionary with a student dict {'name':'john', 'number': 123} | |
| 230 | + returns instance of Test() for that particular student | |
| 231 | + ''' | |
| 232 | + | |
| 233 | + # make list of questions | |
| 234 | + questions = [] | |
| 235 | + qnum = 1 # track question number | |
| 236 | + nerr = 0 # count errors during questions generation | |
| 237 | + | |
| 238 | + for qlist in self['questions']: | |
| 239 | + # choose list of question variants | |
| 240 | + choose = qlist.get('choose', 1) | |
| 241 | + qrefs = random.sample(qlist['ref'], k=choose) | |
| 242 | + | |
| 243 | + for qref in qrefs: | |
| 244 | + # generate instance of question | |
| 245 | + try: | |
| 246 | + question = await self.question_factory[qref].gen_async() | |
| 247 | + except QuestionException: | |
| 248 | + logger.error('Can\'t generate question "%s". Skipping.', qref) | |
| 249 | + nerr += 1 | |
| 250 | + continue | |
| 251 | + | |
| 252 | + # some defaults | |
| 253 | + if question['type'] in ('information', 'success', 'warning', | |
| 254 | + 'alert'): | |
| 255 | + question['points'] = qlist.get('points', 0.0) | |
| 256 | + else: | |
| 257 | + question['points'] = qlist.get('points', 1.0) | |
| 258 | + question['number'] = qnum # counter for non informative panels | |
| 259 | + qnum += 1 | |
| 260 | + | |
| 261 | + questions.append(question) | |
| 262 | + | |
| 263 | + # setup scale | |
| 264 | + total_points = sum(q['points'] for q in questions) | |
| 265 | + | |
| 266 | + if total_points > 0: | |
| 267 | + # normalize question points to scale | |
| 268 | + if self['scale'] is not None: | |
| 269 | + scale_min, scale_max = self['scale'] | |
| 270 | + for question in questions: | |
| 271 | + question['points'] *= (scale_max - scale_min) / total_points | |
| 272 | + else: | |
| 273 | + self['scale'] = [0, total_points] | |
| 274 | + else: | |
| 275 | + logger.warning('Total points is **ZERO**.') | |
| 276 | + if self['scale'] is None: | |
| 277 | + self['scale'] = [0, 20] # default | |
| 278 | + | |
| 279 | + if nerr > 0: | |
| 280 | + logger.error('%s errors found!', nerr) | |
| 281 | + | |
| 282 | + # copy these from the test configuratoin to each test instance | |
| 283 | + inherit = {'ref', 'title', 'database', 'answers_dir', | |
| 284 | + 'questions_dir', 'files', | |
| 285 | + 'duration', 'autosubmit', | |
| 286 | + 'scale', 'show_points', | |
| 287 | + 'show_ref', 'debug', } | |
| 288 | + # NOT INCLUDED: testfile, allow_all, review | |
| 289 | + | |
| 290 | + return Test({'questions': questions, **{k:self[k] for k in inherit}}) | |
| 291 | + | |
| 292 | + # ------------------------------------------------------------------------ | |
| 293 | + def __repr__(self): | |
| 294 | + testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) | |
| 295 | + return '{\n' + testsettings + '\n}' | ... | ... |