diff --git a/BUGS.md b/BUGS.md index b5ce6d4..981281b 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,7 @@ # BUGS +- em practice, depois da submissao o teste corrigido perde as respostas anteriores. perguntas estao todas expostas. - cherrypy faz logs para consola... - mensagens info nao aparecem no serve.py - usar thread.Lock para aceder a variaveis de estado. @@ -20,24 +21,29 @@ # TODO +- refazer questions.py para ter uma classe QuestionFectory? +- refazer serve.py para usar uma classe App() com lógica separada do cherrypy +- controlar acessos dos alunos: allowed/denied/online threadsafe na App() +- argumentos da linha de comando a funcionar. +- permitir adicionar imagens nas perguntas +- aviso na pagina principal para quem usa browser da treta +- permitir enviar varios testes, aluno escolhe qual o teste que quer fazer. +- criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput +- browser e ip usados gravado no test. +- single page web no frontend +- SQLAlchemy em vez da classe database. - script de correcção pode enviar dicionario yaml com grade e comentarios. ex: grade: 0.5 comments: Falhou na função xpto. os comentários são guardados no teste (ficheiro) ou enviados para o browser no modo practice. - warning quando se executa novamente o mesmo teste na consola. ie se ja houver submissoes desse teste. - na cotacao da pergunta indicar o intervalo, e.g. [-0.2, 1], [0, 0.5] -- fazer uma calculadora javascript e por no menu. surge como modal -- SQLAlchemy em vez da classe database. - Criar botão para o docente fazer enable/disable do aluno explicitamente (exames presenciais). -- permitir enviar varios testes, aluno escolhe qual o teste que quer fazer. - criar script json2md.py ou outra forma de gerar um teste ja realizado - Menu para professor com link para /results e /students -- implementar singlepage/multipage. Fazer uma class para single page que trate de andar gerir o avanco e correcao das perguntas -- permitir adicionar imagens nas perguntas -- criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput - perguntas para professor corrigir mais tarde. -- testar com microsoft surface. - share do score em /results (email) +- fazer uma calculadora javascript e por no menu. surge como modal # FIXED diff --git a/config/server.conf b/config/server.conf index 582df67..60d8d08 100644 --- a/config/server.conf +++ b/config/server.conf @@ -23,8 +23,10 @@ server.socket_port = 8080 log.screen = False # add path to the log files here. empty strings disable logging -log.error_file = 'logs/errors.log' -log.access_file = 'logs/access.log' +; log.error_file = 'logs/errors.log' +; log.access_file = 'logs/access.log' +log.error_file = '' +log.access_file = '' # DO NOT DISABLE SESSIONS! tools.sessions.on = True diff --git a/database.py b/database.py index 666c936..f2b77c9 100644 --- a/database.py +++ b/database.py @@ -55,12 +55,12 @@ class Database(object): def save_test(self, t): with sqlite3.connect(self.db) as c: # store result of the test - values = (t['ref'], t['number'], t['grade'], str(t['start_time']), str(t['finish_time'])) + values = (t['ref'], t['student']['number'], t['grade'], str(t['start_time']), str(t['finish_time'])) c.execute('INSERT INTO tests VALUES (?,?,?,?,?)', values) # store grade of every question in the test try: - ans = [(t['ref'], q['ref'], t['number'], q['grade'], str(t['finish_time'])) for q in t['questions']] + ans = [(t['ref'], q['ref'], t['student']['number'], q['grade'], str(t['finish_time'])) for q in t['questions']] except KeyError as e: print(' * Questions {0} do not have grade defined.'.format(tuple(q['ref'] for q in t['questions'] if 'grade' not in q))) raise e diff --git a/questions.py b/questions.py index cd334ee..440bfd1 100644 --- a/questions.py +++ b/questions.py @@ -1,27 +1,22 @@ +# We start with an empty QuestionFactory() that will be populated with +# question generators that we can load from YAML files. +# To generate an instance of a question we use the method generate(ref) where +# the argument is que reference of the question we wish to produce. +# # Example: # # # read everything from question files -# pool = QuestionPool() -# pool.add_from_files(['file1.yaml', 'file1.yaml']) +# factory = QuestionFactory() +# factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to') # -# # generate a new test, creating instances for all questions -# test = [] -# for q in pool.values(): -# test.append(create_question(q)) +# question = factory.generate('some_ref') # # # experiment answering one question and correct it -# test[0]['answer'] = 42 # insert answer -# grade = test[0].correct() # correct answer - - - -# QuestionsPool - dictionary of questions not yet instantiated -# -# question_generator - runs external script to get a question dictionary -# create_question - returns question instance with the correct class +# question['answer'] = 42 # insert answer +# grade = question.correct() # correct answer -# An instance of an actual question is a Question object: +# An instance of an actual question is an object that inherits from Question() # # Question - base class inherited by other classes # QuestionRadio - single choice from a list of options @@ -34,25 +29,24 @@ import random import re import subprocess -import os.path +from os import path import logging import sys +# setup logger for this module +logger = logging.getLogger(__name__) +logger.setLevel(logging.INFO) - -qlogger = logging.getLogger('questions') -qlogger.setLevel(logging.INFO) - -fh = logging.FileHandler('question.log') +# fh = logging.FileHandler('question.log') ch = logging.StreamHandler() ch.setLevel(logging.INFO) formatter = logging.Formatter('%(asctime)s | %(name)-10s | %(levelname)-8s | %(message)s') -fh.setFormatter(formatter) +# fh.setFormatter(formatter) ch.setFormatter(formatter) -qlogger.addHandler(fh) -qlogger.addHandler(ch) +# logger.addHandler(fh) +logger.addHandler(ch) try: import yaml @@ -61,137 +55,144 @@ except ImportError: sys.exit(1) -# if an error occurs in a question, the question is replaced by this message -qerror = { - 'filename': 'questions.py', - 'ref': '__error__', - 'type': 'warning', - 'text': 'An error occurred while generating this question.' - } - -# =========================================================================== -class QuestionsPool(dict): - '''This class contains base questions read from files, but which are - not ready yet. They have to be instantiated for each student.''' - - #------------------------------------------------------------------------ - def add(self, questions, filename, path): - # add some defaults if missing from sources - for i, q in enumerate(questions): - if not isinstance(q, dict): - qlogger.error('Question index {0} from file {1} is not a dictionary. Skipped...'.format(i, filename)) - continue - - if q['ref'] in self: - qlogger.error('Duplicate question "{0}" in files "{1}" and "{2}". Skipped...'.format(q['ref'], filename, self[q['ref']]['filename'])) - continue - - # index is the position in the questions file, 0 based - q.update({ - 'filename': filename, - 'path': path, - 'index': i - }) - q.setdefault('ref', filename + ':' + str(i)) # 'filename.yaml:3' - q.setdefault('type', 'information') - - # add question to the pool - self[q['ref']] = q - qlogger.debug('Added question "{0}" to the pool.'.format(q['ref'])) - - #------------------------------------------------------------------------ - def add_from_files(self, files, path='.'): - '''Given a list of YAML files, reads them all and tries to add - questions to the pool.''' - for filename in files: - try: - with open(os.path.normpath(os.path.join(path, filename)), 'r', encoding='utf-8') as f: - questions = yaml.load(f) - except(FileNotFoundError): - qlogger.error('Questions file "{0}" not found. Skipping this one.'.format(filename)) - continue - except(yaml.parser.ParserError): - qlogger.error('Error loading questions from YAML file "{0}". Skipping this one.'.format(filename)) - continue - self.add(questions, filename, path) - qlogger.info('Loaded {0} questions from "{1}".'.format(len(questions), filename)) - - -#============================================================================ -# Question Factory -# Given a dictionary returns a question instance. -def create_question(q): - '''To create a question, q must be a dictionary with at least the - following keys defined: - filename - ref - type - The remaing keys depend on the type of question. - ''' - - # Depending on the type of question, a different question class is - # instantiated. All these classes derive from the base class `Question`. - types = { - 'radio' : QuestionRadio, - 'checkbox' : QuestionCheckbox, - 'text' : QuestionText, - 'text_regex': QuestionTextRegex, - 'textarea' : QuestionTextArea, - 'information': QuestionInformation, - 'warning' : QuestionInformation, - } - - - # If `q` is of a question generator type, an external program will be run - # and expected to print a valid question in yaml format to stdout. This - # output is then converted to a dictionary and `q` becomes that dict. - if q['type'] == 'generator': - qlogger.debug('Generating question "{0}"...'.format(q['ref'])) - q.update(question_generator(q)) - # At this point the generator question was replaced by an actual question. - - # Get the correct question class for the declared question type - try: - questiontype = types[q['type']] - except KeyError: - qlogger.error('Unsupported question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) - questiontype, q = QuestionWarning, qerror - - # Create question instance and return - try: - qinstance = questiontype(q) - except: - qlogger.error('Could not create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) - qinstance = QuestionInformation(qerror) - - return qinstance - - # --------------------------------------------------------------------------- -def question_generator(q): - '''Run an external program that will generate a question in yaml format. - This function will return the yaml converted back to a dict.''' - - q.setdefault('arg', '') # will be sent to stdin - - script = os.path.abspath(os.path.normpath(os.path.join(q['path'], q['script']))) +# Runs a script and returns its stdout parsed as yaml, or None on error. +# --------------------------------------------------------------------------- +def run_script(script, stdin='', timeout=5): try: - p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) + p = subprocess.run([script], + input=stdin, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + timeout=timeout, + ) except FileNotFoundError: - qlogger.error('Script "{0}" of question "{2}:{1}" not found'.format(script, q['ref'], q['filename'])) - return qerror + logger.error('Script "{0}" not found.'.format(script)) + # return qerror except PermissionError: - qlogger.error('Script "{0}" has wrong permissions. Is it executable?'.format(script, q['ref'], q['filename'])) - return qerror - - try: - qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8') + logger.error('Script "{0}" has wrong permissions. Is it executable?'.format(script)) except subprocess.TimeoutExpired: - p.kill() - qlogger.error('Timeout on script "{0}" of question "{2}:{1}"'.format(script, q['ref'], q['filename'])) - return qerror + logger.error('Timeout {0}s exceeded while running script "{1}"'.format(timeout, script)) + else: + if p.returncode != 0: + logger.warning('Script "{0}" returned error code {1}.'.format(script, p.returncode)) + else: + try: + output = yaml.load(p.stdout) + except: + logger.error('Error parsing yaml output of script "{0}"'.format(script)) + else: + return output + +# =========================================================================== +# This class contains a pool of questions generators from which particular +# Question() instances are generated using QuestionsFactory.generate(ref). +# =========================================================================== +class QuestionFactory(dict): + # ----------------------------------------------------------------------- + def __init__(self): + super().__init__() + + # ----------------------------------------------------------------------- + # Add single question defined provided a dictionary. + # After this, each question will have at least 'ref' and 'type' keys. + # ----------------------------------------------------------------------- + def add(self, question): + # if ref missing try ref='/path/file.yaml:3' + try: + question.setdefault('ref', question['filename'] + ':' + str(question['index'])) + except KeyError: + logger.error('Missing "ref". Cannot add question to the pool.') + return + + # check duplicate references + if question['ref'] in self: + logger.error('Duplicate reference "{0}". Replacing the original one!'.format(question['ref'])) + + question.setdefault('type', 'information') + + self[question['ref']] = question + logger.debug('Added question "{0}" to the pool.'.format(question['ref'])) + + # ----------------------------------------------------------------------- + # load single YAML questions file + # ----------------------------------------------------------------------- + def load_file(self, filename, questions_dir=''): + try: + with open(path.normpath(path.join(questions_dir, filename)), 'r', encoding='utf-8') as f: + questions = yaml.load(f) + except EnvironmentError: + logger.error('Couldn''t open "{0}". Skipped!'.format(file)) + questions = [] + except yaml.parser.ParserError: + logger.error('While loading questions from "{0}". Skipped!'.format(file)) + questions = [] + + n = 0 + for i, q in enumerate(questions): + if isinstance(q, dict): + q.update({ + 'filename': filename, + 'path': questions_dir, + 'index': i # position in the file, 0 based + }) + self.add(q) # add question + n += 1 # counter + else: + logger.error('Question index {0} from file {1} is not a dictionary. Skipped!'.format(i, filename)) + + logger.info('Loaded {0} questions from "{1}" to the pool.'.format(n, filename)) - return yaml.load(qyaml) + # ----------------------------------------------------------------------- + # load multiple YAML question files + # ----------------------------------------------------------------------- + def load_files(self, files, questions_dir=''): + for filename in files: + self.load_file(filename, questions_dir) + + # ----------------------------------------------------------------------- + # Given a ref returns an instance of a descendent of Question(), + # i.e. a question object (radio, checkbox, ...). + # ----------------------------------------------------------------------- + def generate(self, ref): + + # Depending on the type of question, a different question class will be + # instantiated. All these classes derive from the base class `Question`. + types = { + 'radio' : QuestionRadio, + 'checkbox' : QuestionCheckbox, + 'text' : QuestionText, + 'text_regex': QuestionTextRegex, + 'textarea' : QuestionTextArea, + 'information': QuestionInformation, + 'warning' : QuestionInformation, + } + + # Shallow copy so that script generated questions will not replace + # the original generators + q = self[ref].copy() + + # If question is of generator type, an external program will be run + # which will print a valid question in yaml format to stdout. This + # output is then converted to a dictionary and `q` becomes that dict. + if q['type'] == 'generator': + logger.debug('Running script to generate question "{0}".'.format(q['ref'])) + q.setdefault('arg', '') # optional arguments will be sent to stdin + script = path.normpath(path.join(q['path'], q['script'])) + q.update(run_script(script=script, stdin=q['arg'])) + # The generator was replaced by a question but not yet instantiated + + # Finally we create an instance of Question() + try: + qinstance = types[q['type']](q) # instance with correct class + except KeyError: + logger.error('Unknown question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) + except: + logger.error('Failed to create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) + else: + logger.debug('Generated question "{}".'.format(ref)) + return qinstance # =========================================================================== @@ -207,7 +208,7 @@ class Question(dict): def __init__(self, q): super().__init__(q) - # these are mandatory for any question: + # add these if missing self.set_defaults({ 'title': '', 'answer': None, @@ -215,6 +216,7 @@ class Question(dict): def correct(self): self['grade'] = 0.0 + self['comments'] = '' return 0.0 def set_defaults(self, d): @@ -237,7 +239,6 @@ class QuestionRadio(Question): #------------------------------------------------------------------------ def __init__(self, q): - # create key/values as given in q super().__init__(q) # set defaults if missing @@ -256,7 +257,7 @@ class QuestionRadio(Question): self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] if len(self['correct']) != n: - qlogger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) + logger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` @@ -269,17 +270,17 @@ class QuestionRadio(Question): #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): - if self['answer'] is None: - x = 0.0 # zero points if no answer given - else: + super().correct() + + if self['answer'] is not None: x = self['correct'][int(self['answer'])] if self['discount']: n = len(self['options']) # number of options x_aver = sum(self['correct']) / n x = (x - x_aver) / (1.0 - x_aver) + self['grade'] = x - self['grade'] = x - return x + return self['grade'] # =========================================================================== @@ -296,7 +297,6 @@ class QuestionCheckbox(Question): #------------------------------------------------------------------------ def __init__(self, q): - # create key/values as given in q super().__init__(q) n = len(self['options']) @@ -310,7 +310,7 @@ class QuestionCheckbox(Question): }) if len(self['correct']) != n: - qlogger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) + logger.error('Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref'])) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` @@ -323,11 +323,9 @@ class QuestionCheckbox(Question): #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): - if self['answer'] is None: - # not answered - self['grade'] = 0.0 - else: - # answered + super().correct() + + if self['answer'] is not None: sum_abs = sum(abs(p) for p in self['correct']) if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero self['grade'] = 0.0 @@ -358,7 +356,6 @@ class QuestionText(Question): #------------------------------------------------------------------------ def __init__(self, q): - # create key/values as given in q super().__init__(q) self.set_defaults({ @@ -376,11 +373,9 @@ class QuestionText(Question): #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): - if self['answer'] is None: - # not answered - self['grade'] = 0.0 - else: - # answered + super().correct() + + if self['answer'] is not None: self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0 return self['grade'] @@ -397,7 +392,6 @@ class QuestionTextRegex(Question): #------------------------------------------------------------------------ def __init__(self, q): - # create key/values as given in q super().__init__(q) self.set_defaults({ @@ -408,11 +402,8 @@ class QuestionTextRegex(Question): #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): - if self['answer'] is None: - # not answered - self['grade'] = 0.0 - else: - # answered + super().correct() + if self['answer'] is not None: self['grade'] = 1.0 if re.match(self['correct'], self['answer']) else 0.0 return self['grade'] @@ -430,7 +421,6 @@ class QuestionTextArea(Question): #------------------------------------------------------------------------ def __init__(self, q): - # create key/values as given in q super().__init__(q) self.set_defaults({ @@ -439,42 +429,32 @@ class QuestionTextArea(Question): 'timeout': 5, # seconds }) - self['correct'] = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct']))) + self['correct'] = path.abspath(path.normpath(path.join(self['path'], self['correct']))) #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): - if self['answer'] is None: - # not answered - self['grade'] = 0.0 - else: - # answered - try: - p = subprocess.run([self['correct']], - input=self['answer'], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - universal_newlines=True, - timeout=self['timeout'], - ) - except FileNotFoundError: - qlogger.error('Script "{0}" defined in question "{1}" of file "{2}" could not be found.'.format(self['correct'], self['ref'], self['filename'])) - self['grade'] = 0.0 - except PermissionError: - qlogger.error('Script "{0}" has wrong permissions. Is it executable?'.format(self['correct'])) - self['grade'] = 0.0 - except subprocess.TimeoutExpired: - qlogger.warning('Timeout {1}s exceeded while running "{0}"'.format(self['correct'], self['timeout'])) - self['grade'] = 0.0 # student gets a zero if timout occurs - else: - if p.returncode != 0: - qlogger.warning('Script "{0}" returned error code {1}.'.format(self['correct'], p.returncode)) - + super().correct() + + if self['answer'] is not None: + # correct answer + out = run_script( + script=self['correct'], + stdin=self['answer'], + timeout=self['timeout'] + ) + if type(out) in (int, float): + self['grade'] = float(out) + + elif isinstance(out, dict): try: - self['grade'] = float(p.stdout) + self['grade'] = float(out['grade']) except ValueError: - qlogger.error('Correction script of "{0}" returned nonfloat:\n{1}\n'.format(self['ref'], p.stdout)) - self['grade'] = 0.0 + logger.error('Correction script of "{0}" returned nonfloat.'.format(self['ref'])) + except KeyError: + logger.error('Correction script of "{0}" returned no "grade" key.'.format(self['ref'])) + else: + self['comments'] = out.get('comments', '') return self['grade'] @@ -488,17 +468,14 @@ class QuestionInformation(Question): ''' #------------------------------------------------------------------------ def __init__(self, q): - # create key/values as given in q super().__init__(q) - self.set_defaults({ 'text': '', }) - self['points'] = 0.0 # always override the default points of 1.0 - #------------------------------------------------------------------------ # can return negative values for wrong answers def correct(self): + super().correct() self['grade'] = 1.0 # always "correct" but points should be zero! return self['grade'] diff --git a/serve.py b/serve.py index 0302a8a..af4d412 100755 --- a/serve.py +++ b/serve.py @@ -22,24 +22,12 @@ except ImportError: print('The package "mako" is missing. See README.md for instructions.') sys.exit(1) -# path where this file is located -SERVER_PATH = path.dirname(path.realpath(__file__)) -TEMPLATES_DIR = path.join(SERVER_PATH, 'templates') - # my code from myauth import AuthController, require import test import database -ch = logging.StreamHandler() -ch.setLevel(logging.INFO) -ch.setFormatter(logging.Formatter('%(asctime)s | %(name)-10s | %(levelname)-8s | %(message)s')) - -logger = logging.getLogger('serve') -logger.addHandler(ch) - - # ============================================================================ # Classes that respond to HTTP # ============================================================================ @@ -123,14 +111,19 @@ class Root(object): t = cherrypy.session.get('test', None) if t is None: # create instance and add the name and number of the student - cherrypy.session['test'] = t = test.Test(self.testconf) - t['number'] = uid - t['name'] = name + t = self.testconf.generate(number=uid, name=name) + cherrypy.session['test'] = t + + # cherrypy.session['test'] = t = test.Test(self.testconf) + + # t['number'] = uid + # t['name'] = name self.tags['online'].add(uid) # track logged in students + t.reset_answers() # Generate question template = self.templates.get_template('/test.html') - return template.render(t=t, questions=t['questions']) + return template.render(t=t) # --- CORRECT ------------------------------------------------------------ @cherrypy.expose @@ -161,26 +154,29 @@ class Root(object): t.correct() if t['save_answers']: - t.save_json(self.testconf['answers_dir']) + fname = ' -- '.join((t['student']['number'], t['ref'], str(t['finish_time']))) + '.json' + fpath = path.abspath(path.join(t['answers_dir'], fname)) + t.save_json(fpath) + self.database.save_test(t) if t['practice']: # ---- Repeat the test ---- cherrypy.log.error('Student %s terminated with grade = %.2f points.' % - (t['number'], t['grade']), 'APPLICATION') + (t['student']['number'], t['grade']), 'APPLICATION') raise cherrypy.HTTPRedirect('/test') else: # ---- Expire session ---- - self.tags['online'].discard(t['number']) - self.tags['finished'].add(t['number']) + self.tags['online'].discard(t['student']['number']) + self.tags['finished'].add(t['student']['number']) cherrypy.lib.sessions.expire() # session coockie expires client side cherrypy.session['userid'] = cherrypy.request.login = None cherrypy.log.error('Student %s terminated with grade = %.2f points.' % - (t['number'], t['grade']), 'APPLICATION') + (t['student']['number'], t['grade']), 'APPLICATION') # ---- Show result to student ---- - grades = self.database.student_grades(t['number']) + grades = self.database.student_grades(t['student']['number']) template = self.templates.get_template('grade.html') return template.render(t=t, allgrades=grades) @@ -189,33 +185,47 @@ def parse_arguments(): argparser = argparse.ArgumentParser(description='Server for online tests. Enrolled students and tests have to be previously configured. Please read the documentation included with this software before running the server.') serverconf_file = path.normpath(path.join(SERVER_PATH, 'config', 'server.conf')) argparser.add_argument('--server', default=serverconf_file, type=str, help='server configuration file') - argparser.add_argument('--debug', action='store_true', - help='Show datastructures when rendering questions') - argparser.add_argument('--show_ref', action='store_true', - help='Show filename and ref field for each question') - argparser.add_argument('--show_points', action='store_true', - help='Show normalized points for each question') - argparser.add_argument('--show_hints', action='store_true', - help='Show hints in questions, if available') - argparser.add_argument('--save_answers', action='store_true', - help='Saves answers in JSON format') - argparser.add_argument('--practice', action='store_true', - help='Show correction results and allow repetitive resubmission of the test') + # argparser.add_argument('--debug', action='store_true', + # help='Show datastructures when rendering questions') + # argparser.add_argument('--show_ref', action='store_true', + # help='Show filename and ref field for each question') + # argparser.add_argument('--show_points', action='store_true', + # help='Show normalized points for each question') + # argparser.add_argument('--show_hints', action='store_true', + # help='Show hints in questions, if available') + # argparser.add_argument('--save_answers', action='store_true', + # help='Saves answers in JSON format') + # argparser.add_argument('--practice', action='store_true', + # help='Show correction results and allow repetitive resubmission of the test') argparser.add_argument('testfile', type=str, nargs='+', help='test/exam in YAML format.') # FIXME only one exam supported at the moment return argparser.parse_args() # ============================================================================ if __name__ == '__main__': - logger.error('---------- Running perguntations ----------') + ch = logging.StreamHandler() + ch.setLevel(logging.INFO) + ch.setFormatter(logging.Formatter('%(asctime)s | %(name)-10s | %(levelname)-8s | %(message)s')) + + logger = logging.getLogger(__name__) + logger.setLevel(logging.INFO) + logger.addHandler(ch) + + logger.info('============= Running perguntations =============') + + # --- path where this file is located + SERVER_PATH = path.dirname(path.realpath(__file__)) + TEMPLATES_DIR = path.join(SERVER_PATH, 'templates') # --- parse command line arguments and build base test arg = parse_arguments() - testconf = test.read_configuration(arg.testfile[0], debug=arg.debug, show_points=arg.show_points, show_hints=arg.show_hints, save_answers=arg.save_answers, practice=arg.practice, show_ref=arg.show_ref) + logger.info('Reading test configuration.') - # FIXME problems with UnicodeEncodeError - logger.error(' Title: %s' % testconf['title']) - logger.error(' Database: %s' % testconf['database']) # FIXME check if db is ok? + # FIXME do not send args that were not defined in the commandline + # this means options should be like --show-ref=true|false + # and have no default value + filename = path.abspath(path.expanduser(arg.testfile[0])) + testconf = test.TestFactory(filename, conf=vars(arg)) # --- site wide configuration (valid for all apps) cherrypy.config.update({'tools.staticdir.root': SERVER_PATH}) @@ -223,7 +233,7 @@ if __name__ == '__main__': # --- app specific configuration app = cherrypy.tree.mount(Root(testconf), '/', arg.server) - logger.info('Starting server at {}:{}'.format( + logger.info('Webserver listening at {}:{}'.format( cherrypy.config['server.socket_host'], cherrypy.config['server.socket_port'])) diff --git a/templates/grade.html b/templates/grade.html index dc9beb8..8bfe857 100644 --- a/templates/grade.html +++ b/templates/grade.html @@ -54,7 +54,7 @@ -->