from os import path, listdir import sys, fnmatch import random from datetime import datetime import logging # Logger configuration logger = logging.getLogger(__name__) try: # import yaml import json import markdown except ImportError: logger.critical('Python package missing. See README.md for instructions.') sys.exit(1) # my code import questions from tools import load_yaml # =========================================================================== class TestFactoryException(Exception): pass # =========================================================================== # Each instance of TestFactory() is a test generator. # For example, if we want to serve two different tests, then we need two # instances of TestFactory(), one for each test. # =========================================================================== class TestFactory(dict): # ----------------------------------------------------------------------- # loads configuration from yaml file, then updates (overriding) # some configurations using the conf argument. # base questions are loaded from files into a pool. # ----------------------------------------------------------------------- def __init__(self, filename=None, conf={}): if filename is not None: super().__init__(load_yaml(filename)) # load config from file # elif 'testfile' in conf: # super().__init__(load_yaml(conf['testfile'])) # load config from file else: super().__init__({}) # else start empty self['filename'] = filename if filename is not None else '' self.configure(conf) # defaults and sanity checks self.normalize_questions() # to list of dictionaries # loads question_factory self.question_factory = questions.QuestionFactory() self.question_factory.load_files(files=self['files'], questions_dir=self['questions_dir']) # check if all questions exist ('ref' keys are correct?) for q in self['questions']: for r in q['ref']: if r not in self.question_factory: logger.error('Can\'t find question "{}".'.format(r)) logger.info('Test factory ready for "{}".'.format(self['ref'])) # ----------------------------------------------------------------------- # The argument conf is a dictionary containing the test configuration. # It merges conf with the current configuration and performs some checks # ----------------------------------------------------------------------- def configure(self, conf={}): logger.debug('Test factory configuration.') self.update(conf) # check for important missing keys in the test configuration file if 'database' not in self: logger.critical('Missing "database" key in configuration.') raise TestFactoryException() if 'ref' not in self: logger.warning('Missing "ref". Will use current date/time.') if 'answers_dir' not in self: logger.warning('Missing "answers_dir". Will use current directory!') if 'questions_dir' not in self: logger.warning('Missing "questions_dir". Using {}'.format(path.abspath(path.curdir))) if 'files' not in self: logger.warning('Missing "files". Loading all YAML''s from "questions_dir". Not a good idea...') self.setdefault('ref', str(datetime.now())) self.setdefault('title', '') self.setdefault('show_hints', False) self.setdefault('show_points', False) self.setdefault('practice', False) self.setdefault('debug', False) self.setdefault('show_ref', False) self.setdefault('questions_dir', path.curdir) self.setdefault('answers_dir', path.curdir) self['database'] = path.abspath(path.expanduser(self['database'])) self['questions_dir'] = path.abspath(path.expanduser(self['questions_dir'])) self['answers_dir'] = path.abspath(path.expanduser(self['answers_dir'])) if not path.isfile(self['database']): logger.critical('Can\'t find database "{}"'.format(self['database'])) raise TestFactoryException() if not path.isdir(self['questions_dir']): logger.critical('Can\'t find questions directory "{}"'.format(self['questions_dir'])) raise TestFactoryException() # make sure we have a list of question files. # no files were defined ==> load all YAML files from questions_dir if 'files' not in self: try: self['files'] = fnmatch.filter(listdir(self['questions_dir']), '*.yaml') except EnvironmentError: logger.critical('Couldn\'t get list of YAML question files.') raise TestFactoryException() if isinstance(self['files'], str): self['files'] = [self['files']] # FIXME if 'questions' not in self: load all of them try: f = open(path.join(self['answers_dir'],'REMOVE-ME'), 'w') except EnvironmentError: logger.critical('Cannot write answers to "{0}".'.format(self['answers_dir'])) raise TestFactoryException() else: with f: f.write('You can safely remove this file.') # ----------------------------------------------------------------------- # normalize questions to a list of dictionaries # ----------------------------------------------------------------------- def normalize_questions(self): for i, q in enumerate(self['questions']): # normalize question to a dict and ref to a list of references if isinstance(q, str): q = {'ref': [q]} elif isinstance(q, dict) and isinstance(q['ref'], str): q['ref'] = [q['ref']] self['questions'][i] = q # ----------------------------------------------------------------------- # Given a dictionary with a student id {'name':'john', 'number': 123} # returns instance of Test() for that particular student # ----------------------------------------------------------------------- def generate(self, student): test = [] n = 1 for i, qq in enumerate(self['questions']): # generate Question() selected randomly from list of references qref = random.choice(qq['ref']) try: q = self.question_factory.generate(qref) except Exception: logger.error('Cannot generate question with reference "{}". Skipping.'.format(qref)) continue # some defaults if q['type'] in ('information', 'warning', 'alert'): q['points'] = qq.get('points', 0.0) else: q['title'] = '{}. '.format(n) + q['title'] q['points'] = qq.get('points', 1.0) n += 1 test.append(q) return Test({ 'ref': self['ref'], 'title': self['title'], # title of the test 'student': student, # student id 'questions': test, # list of questions 'answers_dir': self['answers_dir'], # FIXME which ones are required? 'show_hints': self['show_hints'], 'show_points': self['show_points'], 'show_ref': self['show_ref'], 'debug': self['debug'], 'database': self['database'], 'questions_dir': self['questions_dir'], 'files': self['files'], }) # ----------------------------------------------------------------------- def __repr__(self): return '{\n' + '\n'.join(' {0:14s}: {1}'.format(k, v) for k,v in self.items()) + '\n}' # =========================================================================== # Each instance of the Test() class is a concrete test to be answered by # a single student. It must/will contain at least these keys: # start_time, finish_time, questions, grade [0,20] # Note: for the save_json() function other keys are required # Note: grades are rounded to 1 decimal point: 0.0 - 20.0 # =========================================================================== class Test(dict): # ----------------------------------------------------------------------- def __init__(self, d): super().__init__(d) self['start_time'] = datetime.now() self['finish_time'] = None self['state'] = 'ONGOING' self['comment'] = '' logger.info('Student {}: starting test.'.format(self['student']['number'])) # ----------------------------------------------------------------------- # Removes all answers from the test (clean) def reset_answers(self): for q in self['questions']: q['answer'] = None logger.info('Student {}: all answers cleared.'.format(self['student']['number'])) # ----------------------------------------------------------------------- # Given a dictionary ans={'someref': 'some answer'} updates the # answers of the test. Only affects questions referred. def update_answers(self, ans): for q in self['questions']: if q['ref'] in ans: q['answer'] = ans[q['ref']] logger.info('Student {}: answers updated.'.format(self['student']['number'])) # ----------------------------------------------------------------------- # Corrects all the answers and computes the final grade def correct(self): self['finish_time'] = datetime.now() self['state'] = 'FINISHED' grade = 0.0 total_points = 0.0 for q in self['questions']: grade += q.correct() * q['points'] total_points += q['points'] if total_points > 0.0: self['grade'] = round(20.0 * max(grade / total_points, 0.0), 1) else: logger.error('Student {}: division by zero during correction. Total points must be positive.'.format(self['student']['number'])) self['grade'] = 0.0 logger.info('Student {}: correction gave {} points.'.format(self['student']['number'], self['grade'])) return self['grade'] # ----------------------------------------------------------------------- def giveup(self): self['finish_time'] = datetime.now() self['state'] = 'QUIT' self['grade'] = 0.0 logger.info('Student {}: gave up.'.format(self['student']['number'])) return self['grade'] # ----------------------------------------------------------------------- def save_json(self, filepath): with open(filepath, 'w') as f: json.dump(self, f, indent=2, default=str) # HACK default=str is required for datetime objects logger.info('Student {}: saved JSON file.'.format(self['student']['number']))