From 9734364e2c46a7f7a778d18df4b1758db259d4c0 Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Tue, 3 Nov 2020 23:47:23 +0000 Subject: [PATCH] add set_answer() to Question base class make questions.py similar to the one in aprendizations checkbox correct now raises error instead of warning about behavior change in values --- demo/questions/questions-tutorial.yaml | 4 ++-- perguntations/app.py | 4 ++-- perguntations/questions.py | 125 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------- perguntations/static/css/test.css | 5 +++++ 4 files changed, 95 insertions(+), 43 deletions(-) diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml index b0e257e..876c1e5 100644 --- a/demo/questions/questions-tutorial.yaml +++ b/demo/questions/questions-tutorial.yaml @@ -227,8 +227,8 @@ ```yaml options: - - ["O céu é azul", "O céu não é azul"] - - ["Um triangulo tem 3 lados", "Um triangulo tem 2 lados"] + - ['O céu é azul', 'O céu não é azul'] + - ['Um triangulo tem 3 lados', 'Um triangulo tem 2 lados'] - O nosso planeta tem um satélite natural correct: [1, 1, 1] ``` diff --git a/perguntations/app.py b/perguntations/app.py index f3f0be4..8f46466 100644 --- a/perguntations/app.py +++ b/perguntations/app.py @@ -105,9 +105,9 @@ class App(): logger.info('Database "%s" has %s students.', dbfile, num) # pre-generate tests - logger.info('Generating tests for %d students:', num) + logger.info('Generating tests for %d students...', num) self._pregenerate_tests(num) - logger.info('Tests are ready.') + logger.info('Tests done.') # command line option --allow-all if conf['allow_all']: diff --git a/perguntations/questions.py b/perguntations/questions.py index 1d429fc..e193cc0 100644 --- a/perguntations/questions.py +++ b/perguntations/questions.py @@ -5,10 +5,11 @@ Classes the implement several types of questions. # python standard library import asyncio +from datetime import datetime import logging +from os import path import random import re -from os import path from typing import Any, Dict, NewType import uuid @@ -48,6 +49,11 @@ class Question(dict): 'files': {}, })) + def set_answer(self, ans) -> None: + '''set answer field and register time''' + self['answer'] = ans + self['finish_time'] = datetime.now() + def correct(self) -> None: '''default correction (synchronous version)''' self['comments'] = '' @@ -80,7 +86,16 @@ class QuestionRadio(Question): def __init__(self, q: QDict) -> None: super().__init__(q) - nopts = len(self['options']) + try: + nopts = len(self['options']) + except KeyError as exc: + msg = f'Missing `options`. In question "{self["ref"]}"' + logger.error(msg) + raise QuestionException(msg) from exc + except TypeError as exc: + msg = f'`options` must be a list. In question "{self["ref"]}"' + logger.error(msg) + raise QuestionException(msg) from exc self.set_defaults(QDict({ 'text': '', @@ -94,8 +109,9 @@ class QuestionRadio(Question): # e.g. correct: 2 --> correct: [0,0,1,0,0] if isinstance(self['correct'], int): if not 0 <= self['correct'] < nopts: - msg = (f'Correct option not in range 0..{nopts-1} in ' - f'"{self["ref"]}"') + msg = (f'`correct` out of range 0..{nopts-1}. ' + f'In question "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) self['correct'] = [1.0 if x == self['correct'] else 0.0 @@ -104,28 +120,33 @@ class QuestionRadio(Question): elif isinstance(self['correct'], list): # must match number of options if len(self['correct']) != nopts: - msg = (f'Incompatible sizes: {nopts} options vs ' - f'{len(self["correct"])} correct in "{self["ref"]}"') + msg = (f'{nopts} options vs {len(self["correct"])} correct. ' + f'In question "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) + # make sure is a list of floats try: self['correct'] = [float(x) for x in self['correct']] except (ValueError, TypeError) as exc: - msg = (f'Correct list must contain numbers [0.0, 1.0] or ' - f'booleans in "{self["ref"]}"') + msg = ('`correct` must be list of numbers or booleans.' + f'In "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): - msg = (f'Correct values must be in the interval [0.0, 1.0] in ' - f'"{self["ref"]}"') + msg = ('`correct` values must be in the interval [0.0, 1.0]. ' + f'In "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) # at least one correct option if all(x < 1.0 for x in self['correct']): - msg = (f'At least one correct option is required in ' - f'"{self["ref"]}"') + msg = ('At least one correct option is required. ' + f'In "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) # If shuffle==false, all options are shown as defined @@ -158,14 +179,17 @@ class QuestionRadio(Question): self['correct'] = [correct[i] for i in perm] # ------------------------------------------------------------------------ - # can assign negative grades for wrong answers def correct(self) -> None: + ''' + Correct `answer` and set `grade`. + Can assign negative grades for wrong answers + ''' super().correct() if self['answer'] is not None: grade = self['correct'][int(self['answer'])] # grade of the answer nopts = len(self['options']) - grade_aver = sum(self['correct']) / nopts # expected value + grade_aver = sum(self['correct']) / nopts # expected value # note: there are no numerical errors when summing 1.0s so the # x_aver can be exactly 1.0 if all options are right @@ -191,7 +215,16 @@ class QuestionCheckbox(Question): def __init__(self, q: QDict) -> None: super().__init__(q) - nopts = len(self['options']) + try: + nopts = len(self['options']) + except KeyError as exc: + msg = f'Missing `options`. In question "{self["ref"]}"' + logger.error(msg) + raise QuestionException(msg) from exc + except TypeError as exc: + msg = f'`options` must be a list. In question "{self["ref"]}"' + logger.error(msg) + raise QuestionException(msg) from exc # set defaults if missing self.set_defaults(QDict({ @@ -199,47 +232,53 @@ class QuestionCheckbox(Question): 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong) 'shuffle': True, 'discount': True, - 'choose': nopts, # number of options + 'choose': nopts, # number of options 'max_tries': max(1, min(nopts - 1, 3)) })) # must be a list of numbers if not isinstance(self['correct'], list): msg = 'Correct must be a list of numbers or booleans' + logger.error(msg) raise QuestionException(msg) # must match number of options if len(self['correct']) != nopts: - msg = (f'Incompatible sizes: {nopts} options vs ' - f'{len(self["correct"])} correct in "{self["ref"]}"') + msg = (f'{nopts} options vs {len(self["correct"])} correct. ' + f'In question "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) # make sure is a list of floats try: self['correct'] = [float(x) for x in self['correct']] except (ValueError, TypeError) as exc: - msg = (f'Correct list must contain numbers or ' - f'booleans in "{self["ref"]}"') + msg = ('`correct` must be list of numbers or booleans.' + f'In "{self["ref"]}"') + logger.error(msg) raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): - - msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') - msg1 = ('| Correct values in checkbox questions must be in the |') - msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') - msg3 = ('| behavior, for now, but you should fix it. |') - msg4 = ('+------------------------------------------------------+') - logger.warning(msg0) - logger.warning(msg1) - logger.warning(msg2) - logger.warning(msg3) - logger.warning(msg4) - logger.warning('please fix "%s"', self["ref"]) - - # normalize to [0,1] - self['correct'] = [(x+1)/2 for x in self['correct']] + msg = ('values in the `correct` field of checkboxes must be in ' + 'the [0.0, 1.0] interval. ' + f'Please fix "{self["ref"]}" in "{self["path"]}"') + logger.error(msg) + raise QuestionException(msg) + # msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') + # msg1 = ('| Correct values in checkbox questions must be in the |') + # msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') + # msg3 = ('| behavior, for now, but you should fix it. |') + # msg4 = ('+------------------------------------------------------+') + # logger.warning(msg0) + # logger.warning(msg1) + # logger.warning(msg2) + # logger.warning(msg3) + # logger.warning(msg4) + # logger.warning('please fix "%s"', self["ref"]) + # # normalize to [0,1] + # self['correct'] = [(x+1)/2 for x in self['correct']] # if an option is a list of (right, wrong), pick one options = [] @@ -381,6 +420,7 @@ class QuestionTextRegex(Question): self['correct'] = [re.compile(a) for a in self['correct']] except Exception as exc: msg = f'Failed to compile regex in "{self["ref"]}"' + logger.error(msg) raise QuestionException(msg) from exc # ------------------------------------------------------------------------ @@ -426,6 +466,7 @@ class QuestionNumericInterval(Question): if len(self['correct']) != 2: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') + logger.error(msg) raise QuestionException(msg) try: @@ -433,12 +474,14 @@ class QuestionNumericInterval(Question): except Exception as exc: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') + logger.error(msg) raise QuestionException(msg) from exc # invalid else: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') + logger.error(msg) raise QuestionException(msg) # ------------------------------------------------------------------------ @@ -629,11 +672,12 @@ class QFactory(): # which will print a valid question in yaml format to stdout. This # output is then yaml parsed into a dictionary `q`. if question['type'] == 'generator': - logger.debug(' \\_ Running "%s".', question["script"]) + logger.debug(' \\_ Running "%s".', question['script']) question.setdefault('args', []) question.setdefault('stdin', '') script = path.join(question['path'], question['script']) - out = await run_script_async(script=script, args=question['args'], + out = await run_script_async(script=script, + args=question['args'], stdin=question['stdin']) question.update(out) @@ -642,14 +686,17 @@ class QFactory(): qclass = self._types[question['type']] except KeyError: logger.error('Invalid type "%s" in "%s"', - question["type"], question["ref"]) + question['type'], question['ref']) raise # Finally create an instance of Question() try: qinstance = qclass(QDict(question)) except QuestionException: - logger.error('Error generating question %s', question['ref']) + logger.error('Error generating question "%s". See "%s/%s"', + question['ref'], + question['path'], + question['filename']) raise return qinstance diff --git a/perguntations/static/css/test.css b/perguntations/static/css/test.css index 2057143..d15d3d6 100644 --- a/perguntations/static/css/test.css +++ b/perguntations/static/css/test.css @@ -8,6 +8,11 @@ body { background: #bbb; } +/*code { + white-space: pre; +} +*/ + /* Hack to avoid name clash between pygments and mathjax */ .MathJax .mo, .MathJax .mi { -- libgit2 0.21.2