test.py 10.8 KB

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):  # FIXME unused
    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 and self.get('save_answers', False):
            logger.warning('Missing "answers_dir". Will use current directory!')
        if 'save_answers' not in self:
            logger.warning('Missing "save_answers". Answers will NOT be saved!')
        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('save_answers', False)
        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
            q = self.question_factory.generate(random.choice(qq['ref']))

            # 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
            'save_answers': self['save_answers'],
            'answers_dir': self['answers_dir'],

            # FIXME which ones are required?
            'practice': self['practice'],
            'show_hints': self['show_hints'],
            'show_points': self['show_points'],
            'show_ref': self['show_ref'],
            'debug': self['debug'],
            # 'answers_dir': self['answers_dir'],
            '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
        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()

        grade = 0.0
        total_points = 0.0
        for q in self['questions']:
            grade += q.correct() * q['points']
            total_points += q['points']

        self['grade'] = round(20.0 * max(grade / total_points, 0.0), 1)
        logger.info('Student {}:  finished with {} points.'.format(self['student']['number'], self['grade']))
        return self['grade']

    # -----------------------------------------------------------------------
    def giveup(self):
        self['comments'] = 'DESISTIU'
        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']))

    # -----------------------------------------------------------------------
    # def generate_html(self):
    #     for q in self['questions']:
    #         q['text_html'] = markdown.markdown(q['text'])