test.py 9.94 KB

# base
from os import path, listdir
import sys, fnmatch
import random
from datetime import datetime
import json
import logging

# project
import questions
from tools import load_yaml

# Logger configuration
logger = logging.getLogger(__name__)

# ===========================================================================
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={}):
        super().__init__({})

        if filename is not None:
            self.update(load_yaml(filename))
            self['filename'] = filename
        else:
            self['filename'] = ''

        self.update(conf)          # overrides configuration
        self.sanity_checks()       # defaults and sanity checks

        # 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']:
                logger.info(f'Checking question "{r}".')
                try:
                    self.question_factory.generate(r)
                except questions.QuestionFactoryException:
                    logger.critical(f'Can\'t generate question "{r}".')
                    raise TestFactoryException()

        logger.info(f'Test factory ready for "{self["ref"]}".')


    # -----------------------------------------------------------------------
    # Checks for valid keys and sets default values.
    # Also checks if some files and directories exist
    # -----------------------------------------------------------------------
    def sanity_checks(self):

        # --- database
        if 'database' not in self:
            logger.critical('Missing "database" key in configuration.')
            raise TestFactoryException()
        elif not path.isfile(path.expanduser(self['database'])):
            logger.critical(f'Can\'t find database {self["database"]}.')
            raise TestFactoryException()

        # --- answers_dir
        if 'answers_dir' not in self:
            logger.warning('Missing "answers_dir".')
            raise TestFactoryException()
        try:  # check if answers_dir is a writable directory
            f = open(path.join(path.expanduser(self['answers_dir']),'REMOVE-ME'), 'w')
        except EnvironmentError:
            logger.critical(f'Cannot write answers to "{self["answers_dir"]}".')
            raise TestFactoryException()
        else:
            with f:
                f.write('You can safely remove this file.')

        # --- ref
        if 'ref' not in self:
            logger.warning('Missing "ref". Will use filename.')
            self['ref'] = self['filename']

        # --- questions_dir
        if 'questions_dir' not in self:
            logger.warning(f'Missing "questions_dir". Using {path.abspath(path.curdir)}')
            self['questions_dir'] = path.curdir
        elif not path.isdir(path.expanduser(self['questions_dir'])):
            logger.critical(f'Can\'t find questions directory "{self["questions_dir"]}"')
            raise TestFactoryException()

        # --- files
        if 'files' not in self:
            logger.warning('Missing "files" key. Loading all YAML files from "questions_dir"... DANGEROUS!!!')
            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']]

        # --- questions
        if 'questions' not in self:
            logger.critical(f'Missing "questions" in {self["filename"]}.')
            raise TestFactoryException()

        # normalize questions to a list of dictionaries
        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


        # --- defaults for optional keys
        self.setdefault('title', '')
        self.setdefault('show_hints', False)
        self.setdefault('show_points', False)
        self.setdefault('debug', False)
        self.setdefault('show_ref', False)
        self.setdefault('duration', 0)  # FIXME unused


    # -----------------------------------------------------------------------
    # Given a dictionary with a student id {'name':'john', 'number': 123}
    # returns instance of Test() for that particular student
    # -----------------------------------------------------------------------
    def generate(self, student):
        test = []
        for qq in self['questions']:
            # generate Question() selected randomly from list of references
            qref = random.choice(qq['ref'])

            try:
                q = self.question_factory.generate(qref)
            except:
                logger.error(f'Can\'t generate question "{qref}". Skipping.')
                continue

            # some defaults
            if q['type'] in ('information', 'warning', 'alert'):
                q['points'] = qq.get('points', 0.0)
            else:
                q['points'] = qq.get('points', 1.0)

            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'],     # required by template test.html
            '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(f'Student {self["student"]["number"]}: starting test.')

    # -----------------------------------------------------------------------
    # Removes all answers from the test (clean)
    def reset_answers(self):
        for q in self['questions']:
            q['answer'] = None
        logger.info(f'Student {self["student"]["number"]}:  all answers cleared.')

    # -----------------------------------------------------------------------
    # Given a dictionary ans={index: 'some answer'} updates the
    # answers of the test. Only affects questions referred.
    def update_answers(self, ans):
        for i in ans:
            self['questions'][i]['answer'] = ans[i]
        logger.info(f'Student {self["student"]["number"]}:  {len(ans)} answers updated.')

    # -----------------------------------------------------------------------
    # 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(f'Student {self["student"]["number"]}:  division by zero during correction. Total points must be positive.')
            self['grade'] = 0.0

        logger.info(f'Student {self["student"]["number"]}:  correction gave {self["grade"]} points.')
        return self['grade']

    # -----------------------------------------------------------------------
    def giveup(self):
        self['finish_time'] = datetime.now()
        self['state'] = 'QUIT'
        self['grade'] = 0.0
        logger.info(f'Student {self["student"]["number"]}:  gave up.')
        return self['grade']

    # -----------------------------------------------------------------------
    def save_json(self, filepath):
        with open(path.expanduser(filepath), 'w') as f:
            json.dump(self, f, indent=2, default=str)
            # HACK default=str is required for datetime objects
        logger.info(f'Student {self["student"]["number"]}:  saved JSON file.')