Commit 96ae3635ade8084928617a596601435ccfbe1776

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

option --correction is now working (needs more testing)

required changes in all questions classes where a new method gen() was
added to generate a question instead of __init__()
demo/demo.yaml
... ... @@ -29,7 +29,7 @@ duration: 20
29 29  
30 30 # Automatic test submission after the given 'duration' timeout
31 31 # (default: false)
32   -autosubmit: true
  32 +autosubmit: false
33 33  
34 34 # If true, the test will be corrected on submission, the grade calculated and
35 35 # shown to the student. If false, the test is saved but not corrected.
... ...
perguntations/app.py
... ... @@ -22,7 +22,7 @@ from perguntations.models import Student, Test, Question
22 22 from perguntations.tools import load_yaml
23 23 from perguntations.testfactory import TestFactory, TestFactoryException
24 24 import perguntations.test
25   -from perguntations.questions import QuestionFrom
  25 +from perguntations.questions import question_from
26 26  
27 27 logger = logging.getLogger(__name__)
28 28  
... ... @@ -129,32 +129,56 @@ class App():
129 129 # ------------------------------------------------------------------------
130 130 def _correct_tests(self):
131 131 with self._db_session() as sess:
132   - filenames = sess.query(Test.filename)\
  132 + # Find which tests have to be corrected
  133 + dbtests = sess.query(Test)\
133 134 .filter(Test.ref == self.testfactory['ref'])\
134 135 .filter(Test.state == "SUBMITTED")\
135 136 .all()
136   - # print([(x.filename, x.state, x.grade) for x in a])
137   - logger.info('Correcting %d tests...', len(filenames))
138   -
139   - for filename, in filenames:
140   - try:
141   - with open(filename) as file:
142   - testdict = json.load(file)
143   - except FileNotFoundError:
144   - logger.error('File not found: %s', filename)
145   - continue
146   -
147   - test = perguntations.test.Test(testdict)
148   - print(test['questions'][7]['correct'])
149   - test['questions'] = [QuestionFrom(q) for q in test['questions']]
150   -
151   - print(test['questions'][7]['correct'])
152   - test.correct()
153   - logger.info('Student %s: grade = %f', test['student']['number'], test['grade'])
154   -
155   -
156   - # FIXME update JSON and database
157 137  
  138 + logger.info('Correcting %d tests...', len(dbtests))
  139 + for dbtest in dbtests:
  140 + try:
  141 + with open(dbtest.filename) as file:
  142 + testdict = json.load(file)
  143 + except FileNotFoundError:
  144 + logger.error('File not found: %s', dbtest.filename)
  145 + continue
  146 +
  147 + # creates a class Test with the methods to correct it
  148 + # the questions are still dictionaries, so we have to call
  149 + # question_from() to produce Question() instances that can be
  150 + # corrected. Finally the test can be corrected.
  151 + test = perguntations.test.Test(testdict)
  152 + test['questions'] = [question_from(q) for q in test['questions']]
  153 + test.correct()
  154 + logger.info('Student %s: grade = %f', test['student']['number'], test['grade'])
  155 +
  156 + # save JSON file (overwriting the old one)
  157 + uid = test['student']['number']
  158 + ref = test['ref']
  159 + finish_time = test['finish_time']
  160 + answers_dir = test['answers_dir']
  161 + fname = f'{uid}--{ref}--{finish_time}.json'
  162 + fpath = path.join(answers_dir, fname)
  163 + test.save_json(fpath)
  164 + logger.info('%s saved JSON file.', uid)
  165 +
  166 + # update database
  167 + dbtest.grade = test['grade']
  168 + dbtest.state = test['state']
  169 + dbtest.questions = [
  170 + Question(
  171 + number=n,
  172 + ref=q['ref'],
  173 + grade=q['grade'],
  174 + comment=q.get('comment', ''),
  175 + starttime=str(test['start_time']),
  176 + finishtime=str(test['finish_time']),
  177 + test_id=test['ref']
  178 + )
  179 + for n, q in enumerate(test['questions'])
  180 + ]
  181 + logger.info('%s database updated.', uid)
158 182  
159 183 # ------------------------------------------------------------------------
160 184 async def login(self, uid, try_pw, headers=None):
... ... @@ -291,11 +315,9 @@ class App():
291 315 logger.info('"%s" grade = %g points.', uid, test['grade'])
292 316  
293 317 # --- save test in JSON format
294   - fields = (uid, test['ref'], str(test['finish_time']))
295   - fname = '--'.join(fields) + '.json'
  318 + fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json'
296 319 fpath = path.join(test['answers_dir'], fname)
297   - with open(path.expanduser(fpath), 'w') as file:
298   - json.dump(test, file, indent=2, default=str) # str for datetime
  320 + test.save_json(fpath)
299 321 logger.info('"%s" saved JSON.', uid)
300 322  
301 323 # --- insert test and questions into the database
... ... @@ -311,19 +333,19 @@ class App():
311 333 filename=fpath,
312 334 student_id=uid)
313 335  
314   - test_row.questions = [
315   - Question(
316   - number=n,
317   - ref=q['ref'],
318   - grade=q['grade'],
319   - comment=q.get('comment', ''),
320   - starttime=str(test['start_time']),
321   - finishtime=str(test['finish_time']),
322   - test_id=test['ref']
323   - )
324   - for n, q in enumerate(test['questions'])
325   - if 'grade' in q
326   - ]
  336 + if test['state'] == 'CORRECTED':
  337 + test_row.questions = [
  338 + Question(
  339 + number=n,
  340 + ref=q['ref'],
  341 + grade=q['grade'],
  342 + comment=q.get('comment', ''),
  343 + starttime=str(test['start_time']),
  344 + finishtime=str(test['finish_time']),
  345 + test_id=test['ref']
  346 + )
  347 + for n, q in enumerate(test['questions'])
  348 + ]
327 349  
328 350 with self._db_session() as sess:
329 351 sess.add(test_row)
... ...
perguntations/questions.py
... ... @@ -14,9 +14,9 @@ from typing import Any, Dict, NewType
14 14 import uuid
15 15  
16 16  
17   -from urllib.error import HTTPError
18   -import json
19   -import http.client
  17 +# from urllib.error import HTTPError
  18 +# import json
  19 +# import http.client
20 20  
21 21  
22 22 # this project
... ... @@ -29,6 +29,8 @@ logger = logging.getLogger(__name__)
29 29 QDict = NewType('QDict', Dict[str, Any])
30 30  
31 31  
  32 +
  33 +
32 34 class QuestionException(Exception):
33 35 '''Exceptions raised in this module'''
34 36  
... ... @@ -43,8 +45,13 @@ class Question(dict):
43 45 for each student.
44 46 Instances can shuffle options or automatically generate questions.
45 47 '''
46   - def __init__(self, q: QDict) -> None:
47   - super().__init__(q)
  48 + # def __init__(self, q: QDict) -> None:
  49 + # super().__init__(q)
  50 +
  51 + def gen(self) -> None:
  52 + '''
  53 + Sets defaults that are valid for any question type
  54 + '''
48 55  
49 56 # add required keys if missing
50 57 self.set_defaults(QDict({
... ... @@ -89,9 +96,15 @@ class QuestionRadio(Question):
89 96 '''
90 97  
91 98 # ------------------------------------------------------------------------
92   - def __init__(self, q: QDict) -> None:
93   - super().__init__(q)
  99 + # def __init__(self, q: QDict) -> None:
  100 + # super().__init__(q)
94 101  
  102 + def gen(self) -> None:
  103 + '''
  104 + Sets defaults, performs checks and generates the actual question
  105 + by modifying the options and correct values
  106 + '''
  107 + super().gen()
95 108 try:
96 109 nopts = len(self['options'])
97 110 except KeyError as exc:
... ... @@ -218,8 +231,11 @@ class QuestionCheckbox(Question):
218 231 '''
219 232  
220 233 # ------------------------------------------------------------------------
221   - def __init__(self, q: QDict) -> None:
222   - super().__init__(q)
  234 + # def __init__(self, q: QDict) -> None:
  235 + # super().__init__(q)
  236 +
  237 + def gen(self) -> None:
  238 + super().gen()
223 239  
224 240 try:
225 241 nopts = len(self['options'])
... ... @@ -340,9 +356,11 @@ class QuestionText(Question):
340 356 '''
341 357  
342 358 # ------------------------------------------------------------------------
343   - def __init__(self, q: QDict) -> None:
344   - super().__init__(q)
  359 + # def __init__(self, q: QDict) -> None:
  360 + # super().__init__(q)
345 361  
  362 + def gen(self) -> None:
  363 + super().gen()
346 364 self.set_defaults(QDict({
347 365 'text': '',
348 366 'correct': [], # no correct answers, always wrong
... ... @@ -409,8 +427,11 @@ class QuestionTextRegex(Question):
409 427 '''
410 428  
411 429 # ------------------------------------------------------------------------
412   - def __init__(self, q: QDict) -> None:
413   - super().__init__(q)
  430 + # def __init__(self, q: QDict) -> None:
  431 + # super().__init__(q)
  432 +
  433 + def gen(self) -> None:
  434 + super().gen()
414 435  
415 436 self.set_defaults(QDict({
416 437 'text': '',
... ... @@ -422,26 +443,34 @@ class QuestionTextRegex(Question):
422 443 self['correct'] = [self['correct']]
423 444  
424 445 # converts patterns to compiled versions
425   - try:
426   - self['correct'] = [re.compile(a) for a in self['correct']]
427   - except Exception as exc:
428   - msg = f'Failed to compile regex in "{self["ref"]}"'
429   - logger.error(msg)
430   - raise QuestionException(msg) from exc
  446 + # try:
  447 + # self['correct'] = [re.compile(a) for a in self['correct']]
  448 + # except Exception as exc:
  449 + # msg = f'Failed to compile regex in "{self["ref"]}"'
  450 + # logger.error(msg)
  451 + # raise QuestionException(msg) from exc
431 452  
432 453 # ------------------------------------------------------------------------
433 454 def correct(self) -> None:
434 455 super().correct()
435 456 if self['answer'] is not None:
436   - self['grade'] = 0.0
437 457 for regex in self['correct']:
438 458 try:
439   - if regex.match(self['answer']):
  459 + if re.fullmatch(regex, self['answer']):
440 460 self['grade'] = 1.0
441 461 return
442 462 except TypeError:
443   - logger.error('While matching regex %s with answer "%s".',
444   - regex.pattern, self["answer"])
  463 + logger.error('While matching regex "%s" with answer "%s".',
  464 + regex, self['answer'])
  465 + self['grade'] = 0.0
  466 +
  467 + # try:
  468 + # if regex.match(self['answer']):
  469 + # self['grade'] = 1.0
  470 + # return
  471 + # except TypeError:
  472 + # logger.error('While matching regex %s with answer "%s".',
  473 + # regex.pattern, self["answer"])
445 474  
446 475  
447 476 # ============================================================================
... ... @@ -455,8 +484,11 @@ class QuestionNumericInterval(Question):
455 484 '''
456 485  
457 486 # ------------------------------------------------------------------------
458   - def __init__(self, q: QDict) -> None:
459   - super().__init__(q)
  487 + # def __init__(self, q: QDict) -> None:
  488 + # super().__init__(q)
  489 +
  490 + def gen(self) -> None:
  491 + super().gen()
460 492  
461 493 self.set_defaults(QDict({
462 494 'text': '',
... ... @@ -516,8 +548,11 @@ class QuestionTextArea(Question):
516 548 '''
517 549  
518 550 # ------------------------------------------------------------------------
519   - def __init__(self, q: QDict) -> None:
520   - super().__init__(q)
  551 + # def __init__(self, q: QDict) -> None:
  552 + # super().__init__(q)
  553 +
  554 + def gen(self) -> None:
  555 + super().gen()
521 556  
522 557 self.set_defaults(QDict({
523 558 'text': '',
... ... @@ -720,8 +755,11 @@ class QuestionInformation(Question):
720 755 '''
721 756  
722 757 # ------------------------------------------------------------------------
723   - def __init__(self, q: QDict) -> None:
724   - super().__init__(q)
  758 + # def __init__(self, q: QDict) -> None:
  759 + # super().__init__(q)
  760 +
  761 + def gen(self) -> None:
  762 + super().gen()
725 763 self.set_defaults(QDict({
726 764 'text': '',
727 765 }))
... ... @@ -733,9 +771,8 @@ class QuestionInformation(Question):
733 771  
734 772  
735 773  
736   -
737 774 # ============================================================================
738   -def question_from(qdict: dict):
  775 +def question_from(qdict: QDict) -> Question:
739 776 '''
740 777 Converts a question specified in a dict into an instance of Question()
741 778 '''
... ... @@ -836,6 +873,7 @@ class QFactory():
836 873 qdict.update(out)
837 874  
838 875 question = question_from(qdict) # returns a Question instance
  876 + question.gen()
839 877 return question
840 878  
841 879 # ------------------------------------------------------------------------
... ...
perguntations/templates/review-question.html
... ... @@ -32,45 +32,46 @@
32 32 </p>
33 33 </div> <!-- card-body -->
34 34  
35   - <div class="card-footer">
36   - {% if q['grade'] > 0.99 %}
37   - <p class="text-success">
38   - <i class="far fa-thumbs-up fa-3x" aria-hidden="true"></i>
39   - {{ round(q['grade'] * q['points'], 2) }}
40   - pontos
41   - </p>
42   - <p class="text-success">{{ md(q['comments']) }}</p>
43   - {% elif q['grade'] > 0.49 %}
44   - <p class="text-warning">
45   - <i class="fas fa-exclamation-triangle fa-3x" aria-hidden="true"></i>
46   - {{ round(q['grade'] * q['points'], 2) }}
47   - pontos
48   - </p>
49   - <p class="text-warning">{{ md(q['comments']) }}</p>
50   - {% if q['solution'] %}
51   - <hr>
52   - {{ md('**Solução:** \n\n' + q['solution']) }}
  35 + {% if 'grade' in q %}
  36 + <div class="card-footer">
  37 + {% if q['grade'] > 0.999 %}
  38 + <p class="text-success">
  39 + <i class="far fa-thumbs-up fa-3x" aria-hidden="true"></i>
  40 + {{ round(q['grade'] * q['points'], 2) }}
  41 + pontos
  42 + </p>
  43 + <p class="text-success">{{ md(q['comments']) }}</p>
  44 + {% elif q['grade'] >= 0.5 %}
  45 + <p class="text-warning">
  46 + <i class="fas fa-exclamation-triangle fa-3x" aria-hidden="true"></i>
  47 + {{ round(q['grade'] * q['points'], 2) }}
  48 + pontos
  49 + </p>
  50 + <p class="text-warning">{{ md(q['comments']) }}</p>
  51 + {% if q['solution'] %}
  52 + <hr>
  53 + {{ md('**Solução:** \n\n' + q['solution']) }}
  54 + {% end %}
  55 + {% else %}
  56 + <p class="text-danger">
  57 + <i class="far fa-thumbs-down fa-3x" aria-hidden="true"></i>
  58 + {{ round(q['grade'] * q['points'], 2) }}
  59 + pontos
  60 + </p>
  61 + <p class="text-danger">{{ md(q['comments']) }}</p>
  62 + {% if q['solution'] %}
  63 + <hr>
  64 + {{ md('**Solução:** \n\n' + q['solution']) }}
  65 + {% end %}
53 66 {% end %}
54   - {% else %}
55   - <p class="text-danger">
56   - <i class="far fa-thumbs-down fa-3x" aria-hidden="true"></i>
57   - {{ round(q['grade'] * q['points'], 2) }}
58   - pontos
59   - </p>
60   - <p class="text-danger">{{ md(q['comments']) }}</p>
61   - {% if q['solution'] %}
  67 +
  68 + {% if t['show_ref'] %}
62 69 <hr>
63   - {{ md('**Solução:** \n\n' + q['solution']) }}
  70 + file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br>
  71 + ref: <code>{{ q['ref'] }}</code>
64 72 {% end %}
65   - {% end %}
66   -
67   - {% if t['show_ref'] %}
68   - <hr>
69   - file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br>
70   - ref: <code>{{ q['ref'] }}</code>
71   - {% end %}
72   -
73   - </div> <!-- card-footer -->
  73 + </div> <!-- card-footer -->
  74 + {% end %}
74 75 </div> <!-- card -->
75 76  
76 77 {% else %}
... ... @@ -97,10 +98,10 @@
97 98 </small>
98 99 </p>
99 100 </div> <!-- card-body -->
  101 +
100 102 <div class="card-footer">
101 103 <p class="text-secondary">
102 104 <i class="fas fa-ban fa-3x" aria-hidden="true"></i>
103   - {{ round(q['grade'] * q['points'], 2) }} pontos<br>
104 105 {{ md(q['comments']) }}
105 106 {% if q['solution'] %}
106 107 <hr>
... ...
perguntations/test.py
... ... @@ -4,8 +4,10 @@ Test - instances of this class are individual tests
4 4  
5 5 # python standard library
6 6 from datetime import datetime
  7 +import json
7 8 import logging
8 9 from math import nan
  10 +from os import path
9 11  
10 12 # Logger configuration
11 13 logger = logging.getLogger(__name__)
... ... @@ -92,6 +94,12 @@ class Test(dict):
92 94 self['grade'] = 0.0
93 95  
94 96 # ------------------------------------------------------------------------
  97 + def save_json(self, pathfile) -> None:
  98 + '''save test in JSON format'''
  99 + with open(pathfile, 'w') as file:
  100 + json.dump(self, file, indent=2, default=str) # str for datetime
  101 +
  102 + # ------------------------------------------------------------------------
95 103 def __str__(self) -> str:
96 104 return '\n'.join([f'{k}: {v}' for k,v in self.items()])
97 105 # return ('Test:\n'
... ...