diff --git a/BUGS.md b/BUGS.md index d8bd949..4606eeb 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,9 @@ # BUGS +- mesmo aluno pode entrar várias vezes em simultaneo... +- ordenar lista de alunos pelos online/offline, e depois pelo numero. +- qd scripts não são executáveis rebenta. Testar isso e dar uma mensagem de erro. - paths manipulation in strings is unix only ('/something'). use os.path to create paths. - alunos vêm nota final arredondada às decimas, mas é apenas um arredondamento visual. Pode acontecer o aluno chumbar, mas ver uma nota positiva (e.g. 9.46 mostra 9.5 e presume que esta aprovado). Mostrar 3 casas? - alunos podem entrar duas vezes em simultaneo. impedir, e permitir ao docente fazer kick-out diff --git a/config/server.conf b/config/server.conf index d5d121d..582df67 100644 --- a/config/server.conf +++ b/config/server.conf @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- [global] -environment= 'production' +; environment= 'production' ; number of threads running server.thread_pool= 10 diff --git a/questions.py b/questions.py index ce67252..ee07379 100644 --- a/questions.py +++ b/questions.py @@ -1,12 +1,5 @@ -import yaml -import random -import re -import subprocess -import sys -import os.path - -# Example usage: +# Example: # # pool = QuestionPool() # pool.add_from_files(['file1.yaml', 'file1.yaml']) @@ -18,6 +11,31 @@ import os.path # test[0]['answer'] = 42 # insert answer # grade = test[0].correct() # correct answer + +# Functions: +# create_question(q) +# q - dictionary with question in yaml format +# returns - question instance with the correct class + + +# An instance of an actual question is a Question object: +# +# Question - base class inherited by other classes +# QuestionRadio - single choice from a list of options +# QuestionCheckbox - multiple choice, equivalent to multiple true/false +# QuestionText - line of text compared to a list of acceptable answers +# QuestionTextRegex - line of text matched against a regular expression +# QuestionTextArea - corrected by an external program +# QuestionInformation - not a question, just a box with content + +import yaml +import random +import re +import subprocess +import sys +import os.path + + # =========================================================================== class QuestionsPool(dict): '''This class contains base questions read from files, but which are @@ -79,10 +97,15 @@ def create_question(q): The remaing keys depend on the type of question. ''' + # 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': q.update(question_generator(q)) - # at this point the generator question was replaced by an actual question + # At this point the generator question was replaced by an actual 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, @@ -97,25 +120,26 @@ def create_question(q): # '' : QuestionInformation, # default } - # create instance of given type + # Get the correct question class for the declared question type try: questiontype = types[q['type']] except KeyError: print('[ ERROR ] Unsupported question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) questiontype = Question - # create question instance and return + # Create question instance and return try: qinstance = questiontype(q) except: print('[ ERROR ] Creating question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) + # FIXME if here, then no qinstance is defined to return... return qinstance # --------------------------------------------------------------------------- def question_generator(q): - '''Run an external script that will generate a question in yaml format. + '''Run an external program that will generate a question in yaml format. This function will return the yaml converted back to a dict.''' q['arg'] = q.get('arg', '') # send this string to stdin @@ -126,11 +150,16 @@ def question_generator(q): except FileNotFoundError: print('[ ERROR ] Script "{0}" of question "{2}:{1}" not found'.format(script, q['ref'], q['filename'])) sys.exit(1) + except PermissionError: + print('[ ERROR ] Script "{0}" of question "{2}:{1}" has wrong permissions. Is it executable?'.format(script, q['ref'], q['filename'])) + sys.exit(1) try: qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8') except subprocess.TimeoutExpired: p.kill() + print('[ ERROR ] Timeout on script "{0}" of question "{2}:{1}"'.format(script, q['ref'], q['filename'])) + sys.exit(1) return yaml.load(qyaml) @@ -146,6 +175,11 @@ class Question(dict): self['grade'] = 0.0 return 0.0 + def set_defaults(self, d): + 'Add k:v pairs from default dict d for nonexistent keys' + for k,v in d.items(): + self.setdefault(k, v) + # =========================================================================== class QuestionRadio(Question): @@ -153,9 +187,9 @@ class QuestionRadio(Question): type (str) text (str) options (list of strings) - shuffle (bool, default True) + shuffle (bool, default=True) correct (list of floats) - discount (bool, default True) + discount (bool, default=True) answer (None or an actual answer) ''' @@ -164,45 +198,32 @@ class QuestionRadio(Question): # create key/values as given in q super().__init__(q) - self['text'] = self.get('text', '') + # set defaults if missing + self.set_defaults({ + 'text': '', + 'correct': 0, + 'shuffle': True, + 'discount': True, + 'answer': None, + }) - # generate an order for the options, e.g. [0,1,2,3,4] n = len(self['options']) - perm = list(range(n)) - - # shuffle the order, e.g. [2,1,4,0,3] - if self.get('shuffle', True): - self['shuffle'] = True - random.shuffle(perm) - else: - self['shuffle'] = False - - # sort options in the given order - options = [None] * n # will contain list with shuffled options - for i, v in enumerate(self['options']): - options[perm[i]] = str(v) # always convert to string - self['options'] = options - # default correct option is the first one - if 'correct' not in self: - self['correct'] = 0 - - # correct can be either an integer with the correct option - # or a list of degrees of correction 0..1 - # always convert to list, e.g. [0,0,1,0,0] + # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] + # correctness levels from 0.0 to 1.0 (no discount here!) if isinstance(self['correct'], int): - correct = [0.0] * n - correct[self['correct']] = 1.0 - self['correct'] = correct + self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] - # sort correct in the given order - correct = [None] * n - for i, v in enumerate(self['correct']): - correct[perm[i]] = float(v) - self['correct'] = correct - self['discount'] = bool(self.get('discount', True)) - self['answer'] = None + if len(self['correct']) != n: + print('[ 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` + if self['shuffle']: + perm = list(range(n)) + random.shuffle(perm) + self['options'] = [ str(self['options'][i]) for i in perm ] + self['correct'] = [ float(self['correct'][i]) for i in perm ] #------------------------------------------------------------------------ # can return negative values for wrong answers @@ -237,38 +258,28 @@ class QuestionCheckbox(Question): # create key/values as given in q super().__init__(q) - self['text'] = self.get('text', '') - - # generate an order for the options, e.g. [0,1,2,3,4] n = len(self['options']) - perm = list(range(n)) - # shuffle the order, e.g. [2,1,4,0,3] - if self.get('shuffle', True): - self['shuffle'] = True + # set defaults if missing + self.set_defaults({ + 'text': '', + 'correct': [0.0] * n, # useful for questionaries + 'shuffle': True, + 'discount': True, + 'answer': None, + }) + + if len(self['correct']) != n: + print('[ 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` + if self['shuffle']: + perm = list(range(n)) random.shuffle(perm) - else: - self['shuffle'] = False - - # sort options in the given order - options = [None] * n # will contain list with shuffled options - for i, v in enumerate(self['options']): - options[perm[i]] = str(v) # always convert to string - self['options'] = options - - # default is to give zero to all options [0,0,...,0] - if 'correct' not in self: - self['correct'] = [0.0] * n + self['options'] = [ str(self['options'][i]) for i in perm ] + self['correct'] = [ float(self['correct'][i]) for i in perm ] - # sort correct in the given order - correct = [None] * n - for i, v in enumerate(self['correct']): - correct[perm[i]] = float(v) - self['correct'] = correct - - self['discount'] = bool(self.get('discount', True)) - - self['answer'] = None #------------------------------------------------------------------------ # can return negative values for wrong answers @@ -279,7 +290,7 @@ class QuestionCheckbox(Question): else: # answered sum_abs = sum(abs(p) for p in self['correct']) - if sum_abs < 1e-6: # in case correct: [0,0,0,0,0] + if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero self['grade'] = 0.0 else: @@ -311,17 +322,18 @@ class QuestionText(Question): # create key/values as given in q super().__init__(q) - self['text'] = self.get('text', '') + self.set_defaults({ + 'text': '', + 'correct': [], + 'answer': None, + }) # make sure its always a list of possible correct answers if not isinstance(self['correct'], list): self['correct'] = [self['correct']] - # make sure the elements of the list are strings - for i, a in enumerate(self['correct']): - self['correct'][i] = str(a) - - self['answer'] = None + # make sure all elements of the list are strings + self['correct'] = [str(a) for a in self['correct']] #------------------------------------------------------------------------ # can return negative values for wrong answers @@ -349,8 +361,12 @@ class QuestionTextRegex(Question): def __init__(self, q): # create key/values as given in q super().__init__(q) - self['text'] = self.get('text', '') - self['answer'] = None + + self.set_defaults({ + 'text': '', + 'correct': '$.^', # will always return false + 'answer': None, + }) #------------------------------------------------------------------------ # can return negative values for wrong answers @@ -379,9 +395,14 @@ class QuestionTextArea(Question): def __init__(self, q): # create key/values as given in q super().__init__(q) - self['text'] = self.get('text', '') - self['answer'] = None - self['lines'] = self.get('lines', 8) + + self.set_defaults({ + 'text': '', + 'answer': None, + 'lines': 8, + }) + + self['correct'] = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct']))) #------------------------------------------------------------------------ # can return negative values for wrong answers @@ -394,29 +415,34 @@ class QuestionTextArea(Question): # The correction program expects data from stdin and prints the result to stdout. # The result should be a string that can be parsed to a float. - script = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct']))) try: - p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT) + p = subprocess.Popen([self['correct']], + stdout=subprocess.PIPE, + stdin=subprocess.PIPE, + stderr=subprocess.STDOUT) except FileNotFoundError as e: - print(' * Script "{0}" defined in question "{1}" of file "{2}" could not be found'.format(self['correct'], self['ref'], self['filename'])) + print('[ ERROR ] Script "{0}" defined in question "{1}" of file "{2}" could not be found'.format(self['correct'], self['ref'], self['filename'])) + raise e + except PermissionError as e: + print('[ ERROR ] Script "{0}" has wrong permissions. Is it executable?'.format(self['correct'])) raise e try: - value = p.communicate(input=self['answer'].encode('utf-8'), timeout=5)[0].decode('utf-8') # esta a dar erro! + value = p.communicate(input=self['answer'].encode('utf-8'), timeout=5)[0].decode('utf-8') except subprocess.TimeoutExpired: p.kill() - # p.communicate() # FIXME parece que este communicate obriga a ficar ate ao final do processo, mas nao consigo repetir o comportamento num script simples... value = 0.0 # student gets a zero if timout occurs - printf(' * Timeout in correction script') + print('[ WARNG ] Timeout in correction script "{0}"'.format(self['correct'])) # In case the correction program returns a non float value, we assume an error # has occurred (for instance, invalid input). We just assume its the student's # fault and give him Zero. try: self['grade'] = float(value) - except (ValueError): + except ValueError as e: self['grade'] = 0.0 - raise Exception('Correction of question "{0}" returned nonfloat "{1}".'.format(self['ref'], value)) + print('[ ERROR ] Correction of question "{0}" returned nonfloat:\n{1}\n'.format(self['ref'], value)) + raise e return self['grade'] @@ -432,12 +458,15 @@ class QuestionInformation(Question): def __init__(self, q): # create key/values as given in q super().__init__(q) - self['text'] = self.get('text', '') - self['points'] = 0.0 # always override the points + + 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): - self['grade'] = 1.0 # always "correct" but points should be zero! + self['grade'] = 0.0 # always "correct" but points should be zero! return self['grade'] - diff --git a/serve.py b/serve.py index fa8364e..be598c4 100755 --- a/serve.py +++ b/serve.py @@ -69,11 +69,15 @@ class Root(object): # --- RESULTS ------------------------------------------------------------ @cherrypy.expose + @require() def results(self): if self.testconf.get('practice', False): + uid = cherrypy.session.get('userid') + name = cherrypy.session.get('name') + r = self.database.test_grades(self.testconf['ref']) template = self.templates.get_template('/results.html') - return template.render(t=self.testconf, results=r) + return template.render(t=self.testconf, results=r, name=name, uid=uid) else: raise cherrypy.HTTPRedirect('/') diff --git a/static/js/question_disabler.js b/static/js/question_disabler.js index 4c406ff..1d1ecea 100644 --- a/static/js/question_disabler.js +++ b/static/js/question_disabler.js @@ -3,7 +3,7 @@ $(document).ready(function() { $("input.question_disabler").change(function () { // Disables the body of the question. // The flip switch is on the bar, and is still accessible. - $(this).parent().next().slideToggle("fast"); + $(this).parent().parent().next().slideToggle("fast"); // $(this).parent().parent().className = "panel panel-info"; }); $(function () { diff --git a/templates/results.html b/templates/results.html index c10cd92..193f1e6 100644 --- a/templates/results.html +++ b/templates/results.html @@ -33,7 +33,8 @@
-