Commit 1db56dc6daf1395250b4ad67060400e5258e2420

Authored by Miguel Barão
2 parents 23b96245 aec96ca5
Exists in master and in 1 other branch dev

Merge branch 'dev'

demo/demo.yaml
... ... @@ -21,7 +21,7 @@ title: Teste de demonstração (tutorial)
21 21  
22 22 # Duration in minutes.
23 23 # (0 or undefined means infinite time)
24   -duration: 10
  24 +duration: 2
25 25 autosubmit: true
26 26  
27 27 # Show points for each question, scale 0-20.
... ...
perguntations/questions.py
  1 +'''
  2 +Classes the implement several types of questions.
  3 +'''
  4 +
1 5  
2 6 # python standard library
3 7 import asyncio
  8 +import logging
4 9 import random
5 10 import re
6 11 from os import path
7   -import logging
8 12 from typing import Any, Dict, NewType
9 13 import uuid
10 14  
... ... @@ -19,7 +23,7 @@ QDict = NewType('QDict', Dict[str, Any])
19 23  
20 24  
21 25 class QuestionException(Exception):
22   - pass
  26 + '''Exceptions raised in this module'''
23 27  
24 28  
25 29 # ============================================================================
... ... @@ -45,16 +49,18 @@ class Question(dict):
45 49 }))
46 50  
47 51 def correct(self) -> None:
  52 + '''default correction (synchronous version)'''
48 53 self['comments'] = ''
49 54 self['grade'] = 0.0
50 55  
51 56 async def correct_async(self) -> None:
  57 + '''default correction (async version)'''
52 58 self.correct()
53 59  
54   - def set_defaults(self, d: QDict) -> None:
55   - 'Add k:v pairs from default dict d for nonexistent keys'
56   - for k, v in d.items():
57   - self.setdefault(k, v)
  60 + def set_defaults(self, qdict: QDict) -> None:
  61 + '''Add k:v pairs from default dict d for nonexistent keys'''
  62 + for k, val in qdict.items():
  63 + self.setdefault(k, val)
58 64  
59 65  
60 66 # ============================================================================
... ... @@ -74,31 +80,31 @@ class QuestionRadio(Question):
74 80 def __init__(self, q: QDict) -> None:
75 81 super().__init__(q)
76 82  
77   - n = len(self['options'])
  83 + nopts = len(self['options'])
78 84  
79 85 self.set_defaults(QDict({
80 86 'text': '',
81 87 'correct': 0,
82 88 'shuffle': True,
83 89 'discount': True,
84   - 'max_tries': (n + 3) // 4 # 1 try for each 4 options
  90 + 'max_tries': (nopts + 3) // 4 # 1 try for each 4 options
85 91 }))
86 92  
87 93 # check correct bounds and convert int to list,
88 94 # e.g. correct: 2 --> correct: [0,0,1,0,0]
89 95 if isinstance(self['correct'], int):
90   - if not (0 <= self['correct'] < n):
91   - msg = (f'Correct option not in range 0..{n-1} in '
  96 + if not 0 <= self['correct'] < nopts:
  97 + msg = (f'Correct option not in range 0..{nopts-1} in '
92 98 f'"{self["ref"]}"')
93 99 raise QuestionException(msg)
94 100  
95 101 self['correct'] = [1.0 if x == self['correct'] else 0.0
96   - for x in range(n)]
  102 + for x in range(nopts)]
97 103  
98 104 elif isinstance(self['correct'], list):
99 105 # must match number of options
100   - if len(self['correct']) != n:
101   - msg = (f'Incompatible sizes: {n} options vs '
  106 + if len(self['correct']) != nopts:
  107 + msg = (f'Incompatible sizes: {nopts} options vs '
102 108 f'{len(self["correct"])} correct in "{self["ref"]}"')
103 109 raise QuestionException(msg)
104 110 # make sure is a list of floats
... ... @@ -126,16 +132,16 @@ class QuestionRadio(Question):
126 132 # otherwise, select 1 correct and choose a few wrong ones
127 133 if self['shuffle']:
128 134 # lists with indices of right and wrong options
129   - right = [i for i in range(n) if self['correct'][i] >= 1]
130   - wrong = [i for i in range(n) if self['correct'][i] < 1]
  135 + right = [i for i in range(nopts) if self['correct'][i] >= 1]
  136 + wrong = [i for i in range(nopts) if self['correct'][i] < 1]
131 137  
132 138 self.set_defaults(QDict({'choose': 1+len(wrong)}))
133 139  
134 140 # try to choose 1 correct option
135 141 if right:
136   - r = random.choice(right)
137   - options = [self['options'][r]]
138   - correct = [self['correct'][r]]
  142 + sel = random.choice(right)
  143 + options = [self['options'][sel]]
  144 + correct = [self['correct'][sel]]
139 145 else:
140 146 options = []
141 147 correct = []
... ... @@ -157,15 +163,15 @@ class QuestionRadio(Question):
157 163 super().correct()
158 164  
159 165 if self['answer'] is not None:
160   - x = self['correct'][int(self['answer'])] # get grade of the answer
161   - n = len(self['options'])
162   - x_aver = sum(self['correct']) / n # expected value of grade
  166 + grade = self['correct'][int(self['answer'])] # grade of the answer
  167 + nopts = len(self['options'])
  168 + grade_aver = sum(self['correct']) / nopts # expected value
163 169  
164 170 # note: there are no numerical errors when summing 1.0s so the
165 171 # x_aver can be exactly 1.0 if all options are right
166   - if self['discount'] and x_aver != 1.0:
167   - x = (x - x_aver) / (1.0 - x_aver)
168   - self['grade'] = x
  172 + if self['discount'] and grade_aver != 1.0:
  173 + grade = (grade - grade_aver) / (1.0 - grade_aver)
  174 + self['grade'] = grade
169 175  
170 176  
171 177 # ============================================================================
... ... @@ -185,16 +191,16 @@ class QuestionCheckbox(Question):
185 191 def __init__(self, q: QDict) -> None:
186 192 super().__init__(q)
187 193  
188   - n = len(self['options'])
  194 + nopts = len(self['options'])
189 195  
190 196 # set defaults if missing
191 197 self.set_defaults(QDict({
192 198 'text': '',
193   - 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options
  199 + 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong)
194 200 'shuffle': True,
195 201 'discount': True,
196   - 'choose': n, # number of options
197   - 'max_tries': max(1, min(n - 1, 3))
  202 + 'choose': nopts, # number of options
  203 + 'max_tries': max(1, min(nopts - 1, 3))
198 204 }))
199 205  
200 206 # must be a list of numbers
... ... @@ -203,8 +209,8 @@ class QuestionCheckbox(Question):
203 209 raise QuestionException(msg)
204 210  
205 211 # must match number of options
206   - if len(self['correct']) != n:
207   - msg = (f'Incompatible sizes: {n} options vs '
  212 + if len(self['correct']) != nopts:
  213 + msg = (f'Incompatible sizes: {nopts} options vs '
208 214 f'{len(self["correct"])} correct in "{self["ref"]}"')
209 215 raise QuestionException(msg)
210 216  
... ... @@ -230,7 +236,7 @@ class QuestionCheckbox(Question):
230 236 logger.warning(msg2)
231 237 logger.warning(msg3)
232 238 logger.warning(msg4)
233   - logger.warning(f'please fix "{self["ref"]}"')
  239 + logger.warning('please fix "%s"', self["ref"])
234 240  
235 241 # normalize to [0,1]
236 242 self['correct'] = [(x+1)/2 for x in self['correct']]
... ... @@ -238,20 +244,19 @@ class QuestionCheckbox(Question):
238 244 # if an option is a list of (right, wrong), pick one
239 245 options = []
240 246 correct = []
241   - for o, c in zip(self['options'], self['correct']):
242   - if isinstance(o, list):
243   - r = random.randint(0, 1)
244   - o = o[r]
245   - if r == 1:
246   - # c = -c
247   - c = 1.0 - c
248   - options.append(str(o))
249   - correct.append(c)
  247 + for option, corr in zip(self['options'], self['correct']):
  248 + if isinstance(option, list):
  249 + sel = random.randint(0, 1)
  250 + option = option[sel]
  251 + if sel == 1:
  252 + corr = 1.0 - corr
  253 + options.append(str(option))
  254 + correct.append(corr)
250 255  
251 256 # generate random permutation, e.g. [2,1,4,0,3]
252 257 # and apply to `options` and `correct`
253 258 if self['shuffle']:
254   - perm = random.sample(range(n), k=self['choose'])
  259 + perm = random.sample(range(nopts), k=self['choose'])
255 260 self['options'] = [options[i] for i in perm]
256 261 self['correct'] = [correct[i] for i in perm]
257 262 else:
... ... @@ -264,18 +269,18 @@ class QuestionCheckbox(Question):
264 269 super().correct()
265 270  
266 271 if self['answer'] is not None:
267   - x = 0.0
  272 + grade = 0.0
268 273 if self['discount']:
269 274 sum_abs = sum(abs(2*p-1) for p in self['correct'])
270   - for i, p in enumerate(self['correct']):
271   - x += 2*p-1 if str(i) in self['answer'] else 1-2*p
  275 + for i, pts in enumerate(self['correct']):
  276 + grade += 2*pts-1 if str(i) in self['answer'] else 1-2*pts
272 277 else:
273 278 sum_abs = sum(abs(p) for p in self['correct'])
274   - for i, p in enumerate(self['correct']):
275   - x += p if str(i) in self['answer'] else 0.0
  279 + for i, pts in enumerate(self['correct']):
  280 + grade += pts if str(i) in self['answer'] else 0.0
276 281  
277 282 try:
278   - self['grade'] = x / sum_abs
  283 + self['grade'] = grade / sum_abs
279 284 except ZeroDivisionError:
280 285 self['grade'] = 1.0 # limit p->0
281 286  
... ... @@ -306,33 +311,35 @@ class QuestionText(Question):
306 311 # make sure all elements of the list are strings
307 312 self['correct'] = [str(a) for a in self['correct']]
308 313  
309   - for f in self['transform']:
310   - if f not in ('remove_space', 'trim', 'normalize_space', 'lower',
311   - 'upper'):
312   - msg = (f'Unknown transform "{f}" in "{self["ref"]}"')
  314 + for transform in self['transform']:
  315 + if transform not in ('remove_space', 'trim', 'normalize_space',
  316 + 'lower', 'upper'):
  317 + msg = (f'Unknown transform "{transform}" in "{self["ref"]}"')
313 318 raise QuestionException(msg)
314 319  
315 320 # check if answers are invariant with respect to the transforms
316 321 if any(c != self.transform(c) for c in self['correct']):
317   - logger.warning(f'in "{self["ref"]}", correct answers are not '
318   - 'invariant wrt transformations => never correct')
  322 + logger.warning('in "%s", correct answers are not invariant wrt '
  323 + 'transformations => never correct', self["ref"])
319 324  
320 325 # ------------------------------------------------------------------------
321   - # apply optional filters to the answer
322 326 def transform(self, ans):
323   - for f in self['transform']:
324   - if f == 'remove_space': # removes all spaces
  327 + '''apply optional filters to the answer'''
  328 +
  329 + for transform in self['transform']:
  330 + if transform == 'remove_space': # removes all spaces
325 331 ans = ans.replace(' ', '')
326   - elif f == 'trim': # removes spaces around
  332 + elif transform == 'trim': # removes spaces around
327 333 ans = ans.strip()
328   - elif f == 'normalize_space': # replaces multiple spaces by one
  334 + elif transform == 'normalize_space': # replaces multiple spaces by one
329 335 ans = re.sub(r'\s+', ' ', ans.strip())
330   - elif f == 'lower': # convert to lowercase
  336 + elif transform == 'lower': # convert to lowercase
331 337 ans = ans.lower()
332   - elif f == 'upper': # convert to uppercase
  338 + elif transform == 'upper': # convert to uppercase
333 339 ans = ans.upper()
334 340 else:
335   - logger.warning(f'in "{self["ref"]}", unknown transform "{f}"')
  341 + logger.warning('in "%s", unknown transform "%s"',
  342 + self["ref"], transform)
336 343 return ans
337 344  
338 345 # ------------------------------------------------------------------------
... ... @@ -381,14 +388,14 @@ class QuestionTextRegex(Question):
381 388 super().correct()
382 389 if self['answer'] is not None:
383 390 self['grade'] = 0.0
384   - for r in self['correct']:
  391 + for regex in self['correct']:
385 392 try:
386   - if r.match(self['answer']):
  393 + if regex.match(self['answer']):
387 394 self['grade'] = 1.0
388 395 return
389 396 except TypeError:
390   - logger.error(f'While matching regex {r.pattern} with '
391   - f'answer "{self["answer"]}".')
  397 + logger.error('While matching regex %s with answer "%s".',
  398 + regex.pattern, self["answer"])
392 399  
393 400  
394 401 # ============================================================================
... ... @@ -485,21 +492,21 @@ class QuestionTextArea(Question):
485 492 )
486 493  
487 494 if out is None:
488   - logger.warning(f'No grade after running "{self["correct"]}".')
  495 + logger.warning('No grade after running "%s".', self["correct"])
489 496 self['grade'] = 0.0
490 497 elif isinstance(out, dict):
491 498 self['comments'] = out.get('comments', '')
492 499 try:
493 500 self['grade'] = float(out['grade'])
494 501 except ValueError:
495   - logger.error(f'Output error in "{self["correct"]}".')
  502 + logger.error('Output error in "%s".', self["correct"])
496 503 except KeyError:
497   - logger.error(f'No grade in "{self["correct"]}".')
  504 + logger.error('No grade in "%s".', self["correct"])
498 505 else:
499 506 try:
500 507 self['grade'] = float(out)
501 508 except (TypeError, ValueError):
502   - logger.error(f'Invalid grade in "{self["correct"]}".')
  509 + logger.error('Invalid grade in "%s".', self["correct"])
503 510  
504 511 # ------------------------------------------------------------------------
505 512 async def correct_async(self) -> None:
... ... @@ -514,25 +521,30 @@ class QuestionTextArea(Question):
514 521 )
515 522  
516 523 if out is None:
517   - logger.warning(f'No grade after running "{self["correct"]}".')
  524 + logger.warning('No grade after running "%s".', self["correct"])
518 525 self['grade'] = 0.0
519 526 elif isinstance(out, dict):
520 527 self['comments'] = out.get('comments', '')
521 528 try:
522 529 self['grade'] = float(out['grade'])
523 530 except ValueError:
524   - logger.error(f'Output error in "{self["correct"]}".')
  531 + logger.error('Output error in "%s".', self["correct"])
525 532 except KeyError:
526   - logger.error(f'No grade in "{self["correct"]}".')
  533 + logger.error('No grade in "%s".', self["correct"])
527 534 else:
528 535 try:
529 536 self['grade'] = float(out)
530 537 except (TypeError, ValueError):
531   - logger.error(f'Invalid grade in "{self["correct"]}".')
  538 + logger.error('Invalid grade in "%s".', self["correct"])
532 539  
533 540  
534 541 # ============================================================================
535 542 class QuestionInformation(Question):
  543 + '''
  544 + Not really a question, just an information panel.
  545 + The correction is always right.
  546 + '''
  547 +
536 548 # ------------------------------------------------------------------------
537 549 def __init__(self, q: QDict) -> None:
538 550 super().__init__(q)
... ... @@ -547,38 +559,38 @@ class QuestionInformation(Question):
547 559  
548 560  
549 561 # ============================================================================
550   -#
551   -# QFactory is a class that can generate question instances, e.g. by shuffling
552   -# options, running a script to generate the question, etc.
553   -#
554   -# To generate an instance of a question we use the method generate().
555   -# It returns a question instance of the correct class.
556   -# There is also an asynchronous version called gen_async(). This version is
557   -# synchronous for all question types (radio, checkbox, etc) except for
558   -# generator types which run asynchronously.
559   -#
560   -# Example:
561   -#
562   -# # make a factory for a question
563   -# qfactory = QFactory({
564   -# 'type': 'radio',
565   -# 'text': 'Choose one',
566   -# 'options': ['a', 'b']
567   -# })
568   -#
569   -# # generate synchronously
570   -# question = qfactory.generate()
571   -#
572   -# # generate asynchronously
573   -# question = await qfactory.gen_async()
574   -#
575   -# # answer one question and correct it
576   -# question['answer'] = 42 # set answer
577   -# question.correct() # correct answer
578   -# grade = question['grade'] # get grade
579   -#
580   -# ============================================================================
581   -class QFactory(object):
  562 +class QFactory():
  563 + '''
  564 + QFactory is a class that can generate question instances, e.g. by shuffling
  565 + options, running a script to generate the question, etc.
  566 +
  567 + To generate an instance of a question we use the method generate().
  568 + It returns a question instance of the correct class.
  569 + There is also an asynchronous version called gen_async(). This version is
  570 + synchronous for all question types (radio, checkbox, etc) except for
  571 + generator types which run asynchronously.
  572 +
  573 + Example:
  574 +
  575 + # make a factory for a question
  576 + qfactory = QFactory({
  577 + 'type': 'radio',
  578 + 'text': 'Choose one',
  579 + 'options': ['a', 'b']
  580 + })
  581 +
  582 + # generate synchronously
  583 + question = qfactory.generate()
  584 +
  585 + # generate asynchronously
  586 + question = await qfactory.gen_async()
  587 +
  588 + # answer one question and correct it
  589 + question['answer'] = 42 # set answer
  590 + question.correct() # correct answer
  591 + grade = question['grade'] # get grade
  592 + '''
  593 +
582 594 # Depending on the type of question, a different question class will be
583 595 # instantiated. All these classes derive from the base class `Question`.
584 596 _types = {
... ... @@ -599,44 +611,48 @@ class QFactory(object):
599 611 self.question = qdict
600 612  
601 613 # ------------------------------------------------------------------------
602   - # generates a question instance of QuestionRadio, QuestionCheckbox, ...,
603   - # which is a descendent of base class Question.
604   - # ------------------------------------------------------------------------
605 614 async def gen_async(self) -> Question:
606   - logger.debug(f'generating {self.question["ref"]}...')
  615 + '''
  616 + generates a question instance of QuestionRadio, QuestionCheckbox, ...,
  617 + which is a descendent of base class Question.
  618 + '''
  619 +
  620 + logger.debug('generating %s...', self.question["ref"])
607 621 # Shallow copy so that script generated questions will not replace
608 622 # the original generators
609   - q = self.question.copy()
610   - q['qid'] = str(uuid.uuid4()) # unique for each generated question
  623 + question = self.question.copy()
  624 + question['qid'] = str(uuid.uuid4()) # unique for each question
611 625  
612 626 # If question is of generator type, an external program will be run
613 627 # which will print a valid question in yaml format to stdout. This
614 628 # output is then yaml parsed into a dictionary `q`.
615   - if q['type'] == 'generator':
616   - logger.debug(f' \\_ Running "{q["script"]}".')
617   - q.setdefault('args', [])
618   - q.setdefault('stdin', '')
619   - script = path.join(q['path'], q['script'])
620   - out = await run_script_async(script=script, args=q['args'],
621   - stdin=q['stdin'])
622   - q.update(out)
  629 + if question['type'] == 'generator':
  630 + logger.debug(' \\_ Running "%s".', question["script"])
  631 + question.setdefault('args', [])
  632 + question.setdefault('stdin', '')
  633 + script = path.join(question['path'], question['script'])
  634 + out = await run_script_async(script=script, args=question['args'],
  635 + stdin=question['stdin'])
  636 + question.update(out)
623 637  
624 638 # Get class for this question type
625 639 try:
626   - qclass = self._types[q['type']]
  640 + qclass = self._types[question['type']]
627 641 except KeyError:
628   - logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
  642 + logger.error('Invalid type "%s" in "%s"',
  643 + question["type"], question["ref"])
629 644 raise
630 645  
631 646 # Finally create an instance of Question()
632 647 try:
633   - qinstance = qclass(QDict(q))
634   - except QuestionException as e:
635   - # logger.error(e)
636   - raise e
  648 + qinstance = qclass(QDict(question))
  649 + except QuestionException:
  650 + logger.error('Error generating question %s', question['ref'])
  651 + raise
637 652  
638 653 return qinstance
639 654  
640 655 # ------------------------------------------------------------------------
641 656 def generate(self) -> Question:
  657 + '''generate question (synchronous version)'''
642 658 return asyncio.get_event_loop().run_until_complete(self.gen_async())
... ...
perguntations/templates/test.html
... ... @@ -38,10 +38,9 @@
38 38 <!-- My scripts -->
39 39 <script defer src="/static/js/question_disabler.js"></script>
40 40 <script defer src="/static/js/prevent_enter_submit.js"></script>
41   -
42 41 </head>
43 42 <!-- ===================================================================== -->
44   -<body id="test">
  43 +<body>
45 44 <!-- ===================================================================== -->
46 45  
47 46 <nav id="navbar" class="navbar navbar-expand-sm fixed-top navbar-dark bg-dark">
... ... @@ -157,37 +156,32 @@
157 156 $("#clock").html("+\u221e");
158 157 {% else %}
159 158  
160   -
161   -
162   - // Update the count down every 1 second
163   - var x = setInterval(function() {
164   - var now = new Date().getTime();
165   - var distance = finishtime - now;
166   -
167   - // Time calculations for days, hours, minutes and seconds
168   - var minutes = Math.floor((distance / (1000 * 60)));
169   - var seconds = Math.floor((distance % (1000 * 60)) / 1000);
170   -
171   - if (distance >= 1000*60) {
172   - $("#clock").html(minutes + ":" + (seconds<10?'0':'') +seconds);
173   - }
174   - else if (distance >= 0) {
175   - $("#navbar").removeClass('bg-dark').addClass("bg-danger");
176   - $("#clock").html(seconds);
177   - }
178   - else {
179   - $("#clock").html(0);
180   - {% if t['autosubmit'] %}
181   - $("#test").submit();
182   - {% end %}
183   - }
184   - }, 1000);
  159 + // Update the count down every 1 second
  160 + var x = setInterval(function() {
  161 + var now = new Date().getTime();
  162 + var distance = finishtime - now;
  163 +
  164 + // Time calculations for days, hours, minutes and seconds
  165 + var minutes = Math.floor((distance / (1000 * 60)));
  166 + var seconds = Math.floor((distance % (1000 * 60)) / 1000);
  167 +
  168 + if (distance >= 1000*60) {
  169 + $("#clock").html(minutes + ":" + (seconds<10?'0':'') +seconds);
  170 + }
  171 + else if (distance >= 0) {
  172 + $("#navbar").removeClass('bg-dark').addClass("bg-danger");
  173 + $("#clock").html(seconds);
  174 + }
  175 + else {
  176 + $("#clock").html(0);
  177 + {% if t['autosubmit'] %}
  178 + $("#test").submit();
  179 + {% end %}
  180 + }
  181 + }, 1000);
185 182  
186 183 {% end %}
187 184  
188 185 </script>
189   -
190   -
191   -
192 186 </body>
193 187 </html>
... ...
perguntations/test.py
  1 +'''
  2 +TestFactory - generates tests for students
  3 +Test - instances of this class are individual tests
  4 +'''
1 5  
2 6  
3 7 # python standard library
... ... @@ -7,7 +11,7 @@ from datetime import datetime
7 11 import logging
8 12  
9 13 # this project
10   -from perguntations.questions import QFactory
  14 +from perguntations.questions import QFactory, QuestionException
11 15 from perguntations.tools import load_yaml
12 16  
13 17 # Logger configuration
... ... @@ -16,7 +20,7 @@ logger = logging.getLogger(__name__)
16 20  
17 21 # ============================================================================
18 22 class TestFactoryException(Exception):
19   - pass
  23 + '''exception raised in this module'''
20 24  
21 25  
22 26 # ============================================================================
... ... @@ -27,7 +31,6 @@ class TestFactory(dict):
27 31 instances of TestFactory(), one for each test.
28 32 '''
29 33  
30   -
31 34 # ------------------------------------------------------------------------
32 35 def __init__(self, conf):
33 36 '''
... ... @@ -120,30 +123,29 @@ class TestFactory(dict):
120 123 if qmissing:
121 124 raise TestFactoryException(f'Could not find questions {qmissing}.')
122 125  
123   - # ------------------------------------------------------------------------
124   - def sanity_checks(self):
125   - '''
126   - Checks for valid keys and sets default values.
127   - Also checks if some files and directories exist
128   - '''
129 126  
130   - # --- ref
  127 + # ------------------------------------------------------------------------
  128 + def check_missing_ref(self):
  129 + '''Test must have a `ref`'''
131 130 if 'ref' not in self:
132 131 raise TestFactoryException('Missing "ref" in configuration!')
133 132  
134   - # --- check database
  133 + def check_missing_database(self):
  134 + '''Test must have a database'''
135 135 if 'database' not in self:
136 136 raise TestFactoryException('Missing "database" in configuration')
137 137 if not path.isfile(path.expanduser(self['database'])):
138 138 msg = f'Database "{self["database"]}" not found!'
139 139 raise TestFactoryException(msg)
140 140  
141   - # --- check answers_dir
  141 + def check_missing_answers_directory(self):
  142 + '''Test must have a answers directory'''
142 143 if 'answers_dir' not in self:
143 144 msg = 'Missing "answers_dir" in configuration'
144 145 raise TestFactoryException(msg)
145 146  
146   - # --- check if answers_dir is a writable directory
  147 + def check_answers_directory_writable(self):
  148 + '''Answers directory must be writable'''
147 149 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
148 150 try:
149 151 with open(testfile, 'w') as file:
... ... @@ -152,82 +154,105 @@ class TestFactory(dict):
152 154 msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
153 155 raise TestFactoryException(msg)
154 156  
155   - # --- check title
156   - if not self['title']:
157   - logger.warning('Undefined title!')
158   -
159   - if self['scale_points']:
160   - smin, smax = self["scale_min"], self["scale_max"]
161   - logger.info('Grades will be scaled to [%g, %g]', smin, smax)
162   - else:
163   - logger.info('Grades are just the sum of points defined for the '
164   - 'questions, not being scaled.')
165   -
166   - # --- questions_dir
  157 + def check_questions_directory(self):
  158 + '''Check if questions directory is missing or not accessible.'''
167 159 if 'questions_dir' not in self:
168   - logger.warning(f'Missing "questions_dir". '
169   - f'Using "{path.abspath(path.curdir)}"')
  160 + logger.warning('Missing "questions_dir". Using "%s"',
  161 + path.abspath(path.curdir))
170 162 self['questions_dir'] = path.curdir
171 163 elif not path.isdir(path.expanduser(self['questions_dir'])):
172 164 raise TestFactoryException(f'Can\'t find questions directory '
173 165 f'"{self["questions_dir"]}"')
174 166  
175   - # --- files
  167 + def check_import_files(self):
  168 + '''Check if there are files to import (with questions)'''
176 169 if 'files' not in self:
177 170 msg = ('Missing "files" in configuration with the list of '
178 171 'question files to import!')
179 172 raise TestFactoryException(msg)
180   - # FIXME allow no files and define the questions directly in the test
181 173  
182 174 if isinstance(self['files'], str):
183 175 self['files'] = [self['files']]
184 176  
185   - # --- questions
  177 + def check_question_list(self):
  178 + '''normalize question list'''
186 179 if 'questions' not in self:
187   - raise TestFactoryException(f'Missing "questions" in Configuration')
  180 + raise TestFactoryException('Missing "questions" in configuration')
188 181  
189   - for i, q in enumerate(self['questions']):
  182 + for i, question in enumerate(self['questions']):
190 183 # normalize question to a dict and ref to a list of references
191   - if isinstance(q, str): # e.g., - some_ref
192   - q = {'ref': [q]} # becomes - ref: [some_ref]
193   - elif isinstance(q, dict) and isinstance(q['ref'], str):
194   - q['ref'] = [q['ref']]
195   - elif isinstance(q, list):
196   - q = {'ref': [str(a) for a in q]}
  184 + if isinstance(question, str): # e.g., - some_ref
  185 + question = {'ref': [question]} # becomes - ref: [some_ref]
  186 + elif isinstance(question, dict) and isinstance(question['ref'], str):
  187 + question['ref'] = [question['ref']]
  188 + elif isinstance(question, list):
  189 + question = {'ref': [str(a) for a in question]}
197 190  
198   - self['questions'][i] = q
  191 + self['questions'][i] = question
  192 +
  193 + def check_missing_title(self):
  194 + '''Warns if title is missing'''
  195 + if not self['title']:
  196 + logger.warning('Title is undefined!')
  197 +
  198 + def check_grade_scaling(self):
  199 + '''Just informs the scale limits'''
  200 + if self['scale_points']:
  201 + smin, smax = self["scale_min"], self["scale_max"]
  202 + logger.info('Grades will be scaled to [%g, %g]', smin, smax)
  203 + else:
  204 + logger.info('Grades are not being scaled.')
199 205  
200 206 # ------------------------------------------------------------------------
201   - # Given a dictionary with a student dict {'name':'john', 'number': 123}
202   - # returns instance of Test() for that particular student
  207 + def sanity_checks(self):
  208 + '''
  209 + Checks for valid keys and sets default values.
  210 + Also checks if some files and directories exist
  211 + '''
  212 + self.check_missing_ref()
  213 + self.check_missing_database()
  214 + self.check_missing_answers_directory()
  215 + self.check_answers_directory_writable()
  216 + self.check_questions_directory()
  217 + self.check_import_files()
  218 + self.check_question_list()
  219 + self.check_missing_title()
  220 + self.check_grade_scaling()
  221 +
203 222 # ------------------------------------------------------------------------
204 223 async def generate(self, student):
  224 + '''
  225 + Given a dictionary with a student dict {'name':'john', 'number': 123}
  226 + returns instance of Test() for that particular student
  227 + '''
  228 +
205 229 # make list of questions
206 230 test = []
207   - n = 1 # track question number
  231 + qnum = 1 # track question number
208 232 nerr = 0 # count errors generating questions
209 233  
210   - for qq in self['questions']:
  234 + for qlist in self['questions']:
211 235 # choose one question variant
212   - qref = random.choice(qq['ref'])
  236 + qref = random.choice(qlist['ref'])
213 237  
214 238 # generate instance of question
215 239 try:
216   - q = await self.question_factory[qref].gen_async()
217   - except Exception:
218   - logger.error(f'Can\'t generate question "{qref}". Skipping.')
  240 + question = await self.question_factory[qref].gen_async()
  241 + except QuestionException:
  242 + logger.error('Can\'t generate question "%s". Skipping.', qref)
219 243 nerr += 1
220 244 continue
221 245  
222 246 # some defaults
223   - if q['type'] in ('information', 'success', 'warning', 'alert'):
224   - q['points'] = qq.get('points', 0.0)
  247 + if question['type'] in ('information', 'success', 'warning',
  248 + 'alert'):
  249 + question['points'] = qlist.get('points', 0.0)
225 250 else:
226   - q['points'] = qq.get('points', 1.0)
227   - q['number'] = n # counter for non informative panels
228   - n += 1
  251 + question['points'] = qlist.get('points', 1.0)
  252 + question['number'] = qnum # counter for non informative panels
  253 + qnum += 1
229 254  
230   - test.append(q)
  255 + test.append(question)
231 256  
232 257 # normalize question points to scale
233 258 if self['scale_points']:
... ... @@ -236,11 +261,11 @@ class TestFactory(dict):
236 261 logger.warning('Can\'t scale, total points in the test is 0!')
237 262 else:
238 263 scale = (self['scale_max'] - self['scale_min']) / total_points
239   - for q in test:
240   - q['points'] *= scale
  264 + for question in test:
  265 + question['points'] *= scale
241 266  
242 267 if nerr > 0:
243   - logger.error(f'{nerr} errors found!')
  268 + logger.error('%s errors found!', nerr)
244 269  
245 270 return Test({
246 271 'ref': self['ref'],
... ... @@ -267,9 +292,11 @@ class TestFactory(dict):
267 292  
268 293  
269 294 # ============================================================================
270   -# Each instance Test() is a concrete test of a single student.
271   -# ============================================================================
272 295 class Test(dict):
  296 + '''
  297 + Each instance Test() is a concrete test of a single student.
  298 + '''
  299 +
273 300 # ------------------------------------------------------------------------
274 301 def __init__(self, d):
275 302 super().__init__(d)
... ... @@ -279,28 +306,33 @@ class Test(dict):
279 306 self['comment'] = ''
280 307  
281 308 # ------------------------------------------------------------------------
282   - # Removes all answers from the test (clean)
283 309 def reset_answers(self):
284   - for q in self['questions']:
285   - q['answer'] = None
  310 + '''Removes all answers from the test (clean)'''
  311 + for question in self['questions']:
  312 + question['answer'] = None
286 313  
287 314 # ------------------------------------------------------------------------
288   - # Given a dictionary ans={'ref': 'some answer'} updates the answers of the
289   - # test. Only affects the questions referred in the dictionary.
290 315 def update_answers(self, ans):
  316 + '''
  317 + Given a dictionary ans={'ref': 'some answer'} updates the answers of
  318 + the test. Only affects the questions referred in the dictionary.
  319 + '''
291 320 for ref, answer in ans.items():
292 321 self['questions'][ref]['answer'] = answer
293 322  
294 323 # ------------------------------------------------------------------------
295   - # Corrects all the answers of the test and computes the final grade
296 324 async def correct(self):
  325 + '''Corrects all the answers of the test and computes the final grade'''
  326 +
297 327 self['finish_time'] = datetime.now()
298 328 self['state'] = 'FINISHED'
299 329 grade = 0.0
300   - for q in self['questions']:
301   - await q.correct_async()
302   - grade += q['grade'] * q['points']
303   - logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%')
  330 + for question in self['questions']:
  331 + await question.correct_async()
  332 + grade += question['grade'] * question['points']
  333 + # logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%')
  334 + logger.debug('Correcting %30s: %3g%%',
  335 + question["ref"], question["grade"]*100)
304 336  
305 337 # truncate to avoid negative grade and adjust scale
306 338 self['grade'] = max(0.0, grade) + self['scale_min']
... ... @@ -308,8 +340,9 @@ class Test(dict):
308 340  
309 341 # ------------------------------------------------------------------------
310 342 def giveup(self):
  343 + '''Test is marqued as QUIT and is not corrected'''
311 344 self['finish_time'] = datetime.now()
312 345 self['state'] = 'QUIT'
313 346 self['grade'] = 0.0
314   - logger.info(f'Student {self["student"]["number"]}: gave up.')
  347 + logger.info('Student %s: gave up.', self["student"]["number"])
315 348 return self['grade']
... ...