Commit 9734364e2c46a7f7a778d18df4b1758db259d4c0

Authored by Miguel Barão
1 parent c7c528c1
Exists in master and in 1 other branch dev

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
@@ -227,8 +227,8 @@ @@ -227,8 +227,8 @@
227 227
228 ```yaml 228 ```yaml
229 options: 229 options:
230 - - ["O céu é azul", "O céu não é azul"]  
231 - - ["Um triangulo tem 3 lados", "Um triangulo tem 2 lados"] 230 + - ['O céu é azul', 'O céu não é azul']
  231 + - ['Um triangulo tem 3 lados', 'Um triangulo tem 2 lados']
232 - O nosso planeta tem um satélite natural 232 - O nosso planeta tem um satélite natural
233 correct: [1, 1, 1] 233 correct: [1, 1, 1]
234 ``` 234 ```
perguntations/app.py
@@ -105,9 +105,9 @@ class App(): @@ -105,9 +105,9 @@ class App():
105 logger.info('Database "%s" has %s students.', dbfile, num) 105 logger.info('Database "%s" has %s students.', dbfile, num)
106 106
107 # pre-generate tests 107 # pre-generate tests
108 - logger.info('Generating tests for %d students:', num) 108 + logger.info('Generating tests for %d students...', num)
109 self._pregenerate_tests(num) 109 self._pregenerate_tests(num)
110 - logger.info('Tests are ready.') 110 + logger.info('Tests done.')
111 111
112 # command line option --allow-all 112 # command line option --allow-all
113 if conf['allow_all']: 113 if conf['allow_all']:
perguntations/questions.py
@@ -5,10 +5,11 @@ Classes the implement several types of questions. @@ -5,10 +5,11 @@ Classes the implement several types of questions.
5 5
6 # python standard library 6 # python standard library
7 import asyncio 7 import asyncio
  8 +from datetime import datetime
8 import logging 9 import logging
  10 +from os import path
9 import random 11 import random
10 import re 12 import re
11 -from os import path  
12 from typing import Any, Dict, NewType 13 from typing import Any, Dict, NewType
13 import uuid 14 import uuid
14 15
@@ -48,6 +49,11 @@ class Question(dict): @@ -48,6 +49,11 @@ class Question(dict):
48 'files': {}, 49 'files': {},
49 })) 50 }))
50 51
  52 + def set_answer(self, ans) -> None:
  53 + '''set answer field and register time'''
  54 + self['answer'] = ans
  55 + self['finish_time'] = datetime.now()
  56 +
51 def correct(self) -> None: 57 def correct(self) -> None:
52 '''default correction (synchronous version)''' 58 '''default correction (synchronous version)'''
53 self['comments'] = '' 59 self['comments'] = ''
@@ -80,7 +86,16 @@ class QuestionRadio(Question): @@ -80,7 +86,16 @@ class QuestionRadio(Question):
80 def __init__(self, q: QDict) -> None: 86 def __init__(self, q: QDict) -> None:
81 super().__init__(q) 87 super().__init__(q)
82 88
83 - nopts = len(self['options']) 89 + try:
  90 + nopts = len(self['options'])
  91 + except KeyError as exc:
  92 + msg = f'Missing `options`. In question "{self["ref"]}"'
  93 + logger.error(msg)
  94 + raise QuestionException(msg) from exc
  95 + except TypeError as exc:
  96 + msg = f'`options` must be a list. In question "{self["ref"]}"'
  97 + logger.error(msg)
  98 + raise QuestionException(msg) from exc
84 99
85 self.set_defaults(QDict({ 100 self.set_defaults(QDict({
86 'text': '', 101 'text': '',
@@ -94,8 +109,9 @@ class QuestionRadio(Question): @@ -94,8 +109,9 @@ class QuestionRadio(Question):
94 # e.g. correct: 2 --> correct: [0,0,1,0,0] 109 # e.g. correct: 2 --> correct: [0,0,1,0,0]
95 if isinstance(self['correct'], int): 110 if isinstance(self['correct'], int):
96 if not 0 <= self['correct'] < nopts: 111 if not 0 <= self['correct'] < nopts:
97 - msg = (f'Correct option not in range 0..{nopts-1} in '  
98 - f'"{self["ref"]}"') 112 + msg = (f'`correct` out of range 0..{nopts-1}. '
  113 + f'In question "{self["ref"]}"')
  114 + logger.error(msg)
99 raise QuestionException(msg) 115 raise QuestionException(msg)
100 116
101 self['correct'] = [1.0 if x == self['correct'] else 0.0 117 self['correct'] = [1.0 if x == self['correct'] else 0.0
@@ -104,28 +120,33 @@ class QuestionRadio(Question): @@ -104,28 +120,33 @@ class QuestionRadio(Question):
104 elif isinstance(self['correct'], list): 120 elif isinstance(self['correct'], list):
105 # must match number of options 121 # must match number of options
106 if len(self['correct']) != nopts: 122 if len(self['correct']) != nopts:
107 - msg = (f'Incompatible sizes: {nopts} options vs '  
108 - f'{len(self["correct"])} correct in "{self["ref"]}"') 123 + msg = (f'{nopts} options vs {len(self["correct"])} correct. '
  124 + f'In question "{self["ref"]}"')
  125 + logger.error(msg)
109 raise QuestionException(msg) 126 raise QuestionException(msg)
  127 +
110 # make sure is a list of floats 128 # make sure is a list of floats
111 try: 129 try:
112 self['correct'] = [float(x) for x in self['correct']] 130 self['correct'] = [float(x) for x in self['correct']]
113 except (ValueError, TypeError) as exc: 131 except (ValueError, TypeError) as exc:
114 - msg = (f'Correct list must contain numbers [0.0, 1.0] or '  
115 - f'booleans in "{self["ref"]}"') 132 + msg = ('`correct` must be list of numbers or booleans.'
  133 + f'In "{self["ref"]}"')
  134 + logger.error(msg)
116 raise QuestionException(msg) from exc 135 raise QuestionException(msg) from exc
117 136
118 # check grade boundaries 137 # check grade boundaries
119 if self['discount'] and not all(0.0 <= x <= 1.0 138 if self['discount'] and not all(0.0 <= x <= 1.0
120 for x in self['correct']): 139 for x in self['correct']):
121 - msg = (f'Correct values must be in the interval [0.0, 1.0] in '  
122 - f'"{self["ref"]}"') 140 + msg = ('`correct` values must be in the interval [0.0, 1.0]. '
  141 + f'In "{self["ref"]}"')
  142 + logger.error(msg)
123 raise QuestionException(msg) 143 raise QuestionException(msg)
124 144
125 # at least one correct option 145 # at least one correct option
126 if all(x < 1.0 for x in self['correct']): 146 if all(x < 1.0 for x in self['correct']):
127 - msg = (f'At least one correct option is required in '  
128 - f'"{self["ref"]}"') 147 + msg = ('At least one correct option is required. '
  148 + f'In "{self["ref"]}"')
  149 + logger.error(msg)
129 raise QuestionException(msg) 150 raise QuestionException(msg)
130 151
131 # If shuffle==false, all options are shown as defined 152 # If shuffle==false, all options are shown as defined
@@ -158,14 +179,17 @@ class QuestionRadio(Question): @@ -158,14 +179,17 @@ class QuestionRadio(Question):
158 self['correct'] = [correct[i] for i in perm] 179 self['correct'] = [correct[i] for i in perm]
159 180
160 # ------------------------------------------------------------------------ 181 # ------------------------------------------------------------------------
161 - # can assign negative grades for wrong answers  
162 def correct(self) -> None: 182 def correct(self) -> None:
  183 + '''
  184 + Correct `answer` and set `grade`.
  185 + Can assign negative grades for wrong answers
  186 + '''
163 super().correct() 187 super().correct()
164 188
165 if self['answer'] is not None: 189 if self['answer'] is not None:
166 grade = self['correct'][int(self['answer'])] # grade of the answer 190 grade = self['correct'][int(self['answer'])] # grade of the answer
167 nopts = len(self['options']) 191 nopts = len(self['options'])
168 - grade_aver = sum(self['correct']) / nopts # expected value 192 + grade_aver = sum(self['correct']) / nopts # expected value
169 193
170 # note: there are no numerical errors when summing 1.0s so the 194 # note: there are no numerical errors when summing 1.0s so the
171 # x_aver can be exactly 1.0 if all options are right 195 # x_aver can be exactly 1.0 if all options are right
@@ -191,7 +215,16 @@ class QuestionCheckbox(Question): @@ -191,7 +215,16 @@ class QuestionCheckbox(Question):
191 def __init__(self, q: QDict) -> None: 215 def __init__(self, q: QDict) -> None:
192 super().__init__(q) 216 super().__init__(q)
193 217
194 - nopts = len(self['options']) 218 + try:
  219 + nopts = len(self['options'])
  220 + except KeyError as exc:
  221 + msg = f'Missing `options`. In question "{self["ref"]}"'
  222 + logger.error(msg)
  223 + raise QuestionException(msg) from exc
  224 + except TypeError as exc:
  225 + msg = f'`options` must be a list. In question "{self["ref"]}"'
  226 + logger.error(msg)
  227 + raise QuestionException(msg) from exc
195 228
196 # set defaults if missing 229 # set defaults if missing
197 self.set_defaults(QDict({ 230 self.set_defaults(QDict({
@@ -199,47 +232,53 @@ class QuestionCheckbox(Question): @@ -199,47 +232,53 @@ class QuestionCheckbox(Question):
199 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong) 232 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong)
200 'shuffle': True, 233 'shuffle': True,
201 'discount': True, 234 'discount': True,
202 - 'choose': nopts, # number of options 235 + 'choose': nopts, # number of options
203 'max_tries': max(1, min(nopts - 1, 3)) 236 'max_tries': max(1, min(nopts - 1, 3))
204 })) 237 }))
205 238
206 # must be a list of numbers 239 # must be a list of numbers
207 if not isinstance(self['correct'], list): 240 if not isinstance(self['correct'], list):
208 msg = 'Correct must be a list of numbers or booleans' 241 msg = 'Correct must be a list of numbers or booleans'
  242 + logger.error(msg)
209 raise QuestionException(msg) 243 raise QuestionException(msg)
210 244
211 # must match number of options 245 # must match number of options
212 if len(self['correct']) != nopts: 246 if len(self['correct']) != nopts:
213 - msg = (f'Incompatible sizes: {nopts} options vs '  
214 - f'{len(self["correct"])} correct in "{self["ref"]}"') 247 + msg = (f'{nopts} options vs {len(self["correct"])} correct. '
  248 + f'In question "{self["ref"]}"')
  249 + logger.error(msg)
215 raise QuestionException(msg) 250 raise QuestionException(msg)
216 251
217 # make sure is a list of floats 252 # make sure is a list of floats
218 try: 253 try:
219 self['correct'] = [float(x) for x in self['correct']] 254 self['correct'] = [float(x) for x in self['correct']]
220 except (ValueError, TypeError) as exc: 255 except (ValueError, TypeError) as exc:
221 - msg = (f'Correct list must contain numbers or '  
222 - f'booleans in "{self["ref"]}"') 256 + msg = ('`correct` must be list of numbers or booleans.'
  257 + f'In "{self["ref"]}"')
  258 + logger.error(msg)
223 raise QuestionException(msg) from exc 259 raise QuestionException(msg) from exc
224 260
225 # check grade boundaries 261 # check grade boundaries
226 if self['discount'] and not all(0.0 <= x <= 1.0 262 if self['discount'] and not all(0.0 <= x <= 1.0
227 for x in self['correct']): 263 for x in self['correct']):
228 -  
229 - msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+')  
230 - msg1 = ('| Correct values in checkbox questions must be in the |')  
231 - msg2 = ('| interval [0.0, 1.0]. I will convert to the new |')  
232 - msg3 = ('| behavior, for now, but you should fix it. |')  
233 - msg4 = ('+------------------------------------------------------+')  
234 - logger.warning(msg0)  
235 - logger.warning(msg1)  
236 - logger.warning(msg2)  
237 - logger.warning(msg3)  
238 - logger.warning(msg4)  
239 - logger.warning('please fix "%s"', self["ref"])  
240 -  
241 - # normalize to [0,1]  
242 - self['correct'] = [(x+1)/2 for x in self['correct']] 264 + msg = ('values in the `correct` field of checkboxes must be in '
  265 + 'the [0.0, 1.0] interval. '
  266 + f'Please fix "{self["ref"]}" in "{self["path"]}"')
  267 + logger.error(msg)
  268 + raise QuestionException(msg)
  269 + # msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+')
  270 + # msg1 = ('| Correct values in checkbox questions must be in the |')
  271 + # msg2 = ('| interval [0.0, 1.0]. I will convert to the new |')
  272 + # msg3 = ('| behavior, for now, but you should fix it. |')
  273 + # msg4 = ('+------------------------------------------------------+')
  274 + # logger.warning(msg0)
  275 + # logger.warning(msg1)
  276 + # logger.warning(msg2)
  277 + # logger.warning(msg3)
  278 + # logger.warning(msg4)
  279 + # logger.warning('please fix "%s"', self["ref"])
  280 + # # normalize to [0,1]
  281 + # self['correct'] = [(x+1)/2 for x in self['correct']]
243 282
244 # if an option is a list of (right, wrong), pick one 283 # if an option is a list of (right, wrong), pick one
245 options = [] 284 options = []
@@ -381,6 +420,7 @@ class QuestionTextRegex(Question): @@ -381,6 +420,7 @@ class QuestionTextRegex(Question):
381 self['correct'] = [re.compile(a) for a in self['correct']] 420 self['correct'] = [re.compile(a) for a in self['correct']]
382 except Exception as exc: 421 except Exception as exc:
383 msg = f'Failed to compile regex in "{self["ref"]}"' 422 msg = f'Failed to compile regex in "{self["ref"]}"'
  423 + logger.error(msg)
384 raise QuestionException(msg) from exc 424 raise QuestionException(msg) from exc
385 425
386 # ------------------------------------------------------------------------ 426 # ------------------------------------------------------------------------
@@ -426,6 +466,7 @@ class QuestionNumericInterval(Question): @@ -426,6 +466,7 @@ class QuestionNumericInterval(Question):
426 if len(self['correct']) != 2: 466 if len(self['correct']) != 2:
427 msg = (f'Numeric interval must be a list with two numbers, in ' 467 msg = (f'Numeric interval must be a list with two numbers, in '
428 f'{self["ref"]}') 468 f'{self["ref"]}')
  469 + logger.error(msg)
429 raise QuestionException(msg) 470 raise QuestionException(msg)
430 471
431 try: 472 try:
@@ -433,12 +474,14 @@ class QuestionNumericInterval(Question): @@ -433,12 +474,14 @@ class QuestionNumericInterval(Question):
433 except Exception as exc: 474 except Exception as exc:
434 msg = (f'Numeric interval must be a list with two numbers, in ' 475 msg = (f'Numeric interval must be a list with two numbers, in '
435 f'{self["ref"]}') 476 f'{self["ref"]}')
  477 + logger.error(msg)
436 raise QuestionException(msg) from exc 478 raise QuestionException(msg) from exc
437 479
438 # invalid 480 # invalid
439 else: 481 else:
440 msg = (f'Numeric interval must be a list with two numbers, in ' 482 msg = (f'Numeric interval must be a list with two numbers, in '
441 f'{self["ref"]}') 483 f'{self["ref"]}')
  484 + logger.error(msg)
442 raise QuestionException(msg) 485 raise QuestionException(msg)
443 486
444 # ------------------------------------------------------------------------ 487 # ------------------------------------------------------------------------
@@ -629,11 +672,12 @@ class QFactory(): @@ -629,11 +672,12 @@ class QFactory():
629 # which will print a valid question in yaml format to stdout. This 672 # which will print a valid question in yaml format to stdout. This
630 # output is then yaml parsed into a dictionary `q`. 673 # output is then yaml parsed into a dictionary `q`.
631 if question['type'] == 'generator': 674 if question['type'] == 'generator':
632 - logger.debug(' \\_ Running "%s".', question["script"]) 675 + logger.debug(' \\_ Running "%s".', question['script'])
633 question.setdefault('args', []) 676 question.setdefault('args', [])
634 question.setdefault('stdin', '') 677 question.setdefault('stdin', '')
635 script = path.join(question['path'], question['script']) 678 script = path.join(question['path'], question['script'])
636 - out = await run_script_async(script=script, args=question['args'], 679 + out = await run_script_async(script=script,
  680 + args=question['args'],
637 stdin=question['stdin']) 681 stdin=question['stdin'])
638 question.update(out) 682 question.update(out)
639 683
@@ -642,14 +686,17 @@ class QFactory(): @@ -642,14 +686,17 @@ class QFactory():
642 qclass = self._types[question['type']] 686 qclass = self._types[question['type']]
643 except KeyError: 687 except KeyError:
644 logger.error('Invalid type "%s" in "%s"', 688 logger.error('Invalid type "%s" in "%s"',
645 - question["type"], question["ref"]) 689 + question['type'], question['ref'])
646 raise 690 raise
647 691
648 # Finally create an instance of Question() 692 # Finally create an instance of Question()
649 try: 693 try:
650 qinstance = qclass(QDict(question)) 694 qinstance = qclass(QDict(question))
651 except QuestionException: 695 except QuestionException:
652 - logger.error('Error generating question %s', question['ref']) 696 + logger.error('Error generating question "%s". See "%s/%s"',
  697 + question['ref'],
  698 + question['path'],
  699 + question['filename'])
653 raise 700 raise
654 701
655 return qinstance 702 return qinstance
perguntations/static/css/test.css
@@ -8,6 +8,11 @@ body { @@ -8,6 +8,11 @@ body {
8 background: #bbb; 8 background: #bbb;
9 } 9 }
10 10
  11 +/*code {
  12 + white-space: pre;
  13 +}
  14 +*/
  15 +
11 /* Hack to avoid name clash between pygments and mathjax */ 16 /* Hack to avoid name clash between pygments and mathjax */
12 .MathJax .mo, 17 .MathJax .mo,
13 .MathJax .mi { 18 .MathJax .mi {