Commit 9734364e2c46a7f7a778d18df4b1758db259d4c0
1 parent
c7c528c1
Exists in
master
and in
1 other branch
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
Showing
4 changed files
with
95 additions
and
43 deletions
Show diff stats
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 { |