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 227  
228 228 ```yaml
229 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 232 - O nosso planeta tem um satélite natural
233 233 correct: [1, 1, 1]
234 234 ```
... ...
perguntations/app.py
... ... @@ -105,9 +105,9 @@ class App():
105 105 logger.info('Database "%s" has %s students.', dbfile, num)
106 106  
107 107 # pre-generate tests
108   - logger.info('Generating tests for %d students:', num)
  108 + logger.info('Generating tests for %d students...', num)
109 109 self._pregenerate_tests(num)
110   - logger.info('Tests are ready.')
  110 + logger.info('Tests done.')
111 111  
112 112 # command line option --allow-all
113 113 if conf['allow_all']:
... ...
perguntations/questions.py
... ... @@ -5,10 +5,11 @@ Classes the implement several types of questions.
5 5  
6 6 # python standard library
7 7 import asyncio
  8 +from datetime import datetime
8 9 import logging
  10 +from os import path
9 11 import random
10 12 import re
11   -from os import path
12 13 from typing import Any, Dict, NewType
13 14 import uuid
14 15  
... ... @@ -48,6 +49,11 @@ class Question(dict):
48 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 57 def correct(self) -> None:
52 58 '''default correction (synchronous version)'''
53 59 self['comments'] = ''
... ... @@ -80,7 +86,16 @@ class QuestionRadio(Question):
80 86 def __init__(self, q: QDict) -> None:
81 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 100 self.set_defaults(QDict({
86 101 'text': '',
... ... @@ -94,8 +109,9 @@ class QuestionRadio(Question):
94 109 # e.g. correct: 2 --> correct: [0,0,1,0,0]
95 110 if isinstance(self['correct'], int):
96 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 115 raise QuestionException(msg)
100 116  
101 117 self['correct'] = [1.0 if x == self['correct'] else 0.0
... ... @@ -104,28 +120,33 @@ class QuestionRadio(Question):
104 120 elif isinstance(self['correct'], list):
105 121 # must match number of options
106 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 126 raise QuestionException(msg)
  127 +
110 128 # make sure is a list of floats
111 129 try:
112 130 self['correct'] = [float(x) for x in self['correct']]
113 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 135 raise QuestionException(msg) from exc
117 136  
118 137 # check grade boundaries
119 138 if self['discount'] and not all(0.0 <= x <= 1.0
120 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 143 raise QuestionException(msg)
124 144  
125 145 # at least one correct option
126 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 150 raise QuestionException(msg)
130 151  
131 152 # If shuffle==false, all options are shown as defined
... ... @@ -158,14 +179,17 @@ class QuestionRadio(Question):
158 179 self['correct'] = [correct[i] for i in perm]
159 180  
160 181 # ------------------------------------------------------------------------
161   - # can assign negative grades for wrong answers
162 182 def correct(self) -> None:
  183 + '''
  184 + Correct `answer` and set `grade`.
  185 + Can assign negative grades for wrong answers
  186 + '''
163 187 super().correct()
164 188  
165 189 if self['answer'] is not None:
166 190 grade = self['correct'][int(self['answer'])] # grade of the answer
167 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 194 # note: there are no numerical errors when summing 1.0s so the
171 195 # x_aver can be exactly 1.0 if all options are right
... ... @@ -191,7 +215,16 @@ class QuestionCheckbox(Question):
191 215 def __init__(self, q: QDict) -> None:
192 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 229 # set defaults if missing
197 230 self.set_defaults(QDict({
... ... @@ -199,47 +232,53 @@ class QuestionCheckbox(Question):
199 232 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong)
200 233 'shuffle': True,
201 234 'discount': True,
202   - 'choose': nopts, # number of options
  235 + 'choose': nopts, # number of options
203 236 'max_tries': max(1, min(nopts - 1, 3))
204 237 }))
205 238  
206 239 # must be a list of numbers
207 240 if not isinstance(self['correct'], list):
208 241 msg = 'Correct must be a list of numbers or booleans'
  242 + logger.error(msg)
209 243 raise QuestionException(msg)
210 244  
211 245 # must match number of options
212 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 250 raise QuestionException(msg)
216 251  
217 252 # make sure is a list of floats
218 253 try:
219 254 self['correct'] = [float(x) for x in self['correct']]
220 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 259 raise QuestionException(msg) from exc
224 260  
225 261 # check grade boundaries
226 262 if self['discount'] and not all(0.0 <= x <= 1.0
227 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 283 # if an option is a list of (right, wrong), pick one
245 284 options = []
... ... @@ -381,6 +420,7 @@ class QuestionTextRegex(Question):
381 420 self['correct'] = [re.compile(a) for a in self['correct']]
382 421 except Exception as exc:
383 422 msg = f'Failed to compile regex in "{self["ref"]}"'
  423 + logger.error(msg)
384 424 raise QuestionException(msg) from exc
385 425  
386 426 # ------------------------------------------------------------------------
... ... @@ -426,6 +466,7 @@ class QuestionNumericInterval(Question):
426 466 if len(self['correct']) != 2:
427 467 msg = (f'Numeric interval must be a list with two numbers, in '
428 468 f'{self["ref"]}')
  469 + logger.error(msg)
429 470 raise QuestionException(msg)
430 471  
431 472 try:
... ... @@ -433,12 +474,14 @@ class QuestionNumericInterval(Question):
433 474 except Exception as exc:
434 475 msg = (f'Numeric interval must be a list with two numbers, in '
435 476 f'{self["ref"]}')
  477 + logger.error(msg)
436 478 raise QuestionException(msg) from exc
437 479  
438 480 # invalid
439 481 else:
440 482 msg = (f'Numeric interval must be a list with two numbers, in '
441 483 f'{self["ref"]}')
  484 + logger.error(msg)
442 485 raise QuestionException(msg)
443 486  
444 487 # ------------------------------------------------------------------------
... ... @@ -629,11 +672,12 @@ class QFactory():
629 672 # which will print a valid question in yaml format to stdout. This
630 673 # output is then yaml parsed into a dictionary `q`.
631 674 if question['type'] == 'generator':
632   - logger.debug(' \\_ Running "%s".', question["script"])
  675 + logger.debug(' \\_ Running "%s".', question['script'])
633 676 question.setdefault('args', [])
634 677 question.setdefault('stdin', '')
635 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 681 stdin=question['stdin'])
638 682 question.update(out)
639 683  
... ... @@ -642,14 +686,17 @@ class QFactory():
642 686 qclass = self._types[question['type']]
643 687 except KeyError:
644 688 logger.error('Invalid type "%s" in "%s"',
645   - question["type"], question["ref"])
  689 + question['type'], question['ref'])
646 690 raise
647 691  
648 692 # Finally create an instance of Question()
649 693 try:
650 694 qinstance = qclass(QDict(question))
651 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 700 raise
654 701  
655 702 return qinstance
... ...
perguntations/static/css/test.css
... ... @@ -8,6 +8,11 @@ body {
8 8 background: #bbb;
9 9 }
10 10  
  11 +/*code {
  12 + white-space: pre;
  13 +}
  14 +*/
  15 +
11 16 /* Hack to avoid name clash between pygments and mathjax */
12 17 .MathJax .mo,
13 18 .MathJax .mi {
... ...