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,7 +29,7 @@ duration: 20
29 29
30 # Automatic test submission after the given 'duration' timeout 30 # Automatic test submission after the given 'duration' timeout
31 # (default: false) 31 # (default: false)
32 -autosubmit: true 32 +autosubmit: false
33 33
34 # If true, the test will be corrected on submission, the grade calculated and 34 # If true, the test will be corrected on submission, the grade calculated and
35 # shown to the student. If false, the test is saved but not corrected. 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,7 +22,7 @@ from perguntations.models import Student, Test, Question
22 from perguntations.tools import load_yaml 22 from perguntations.tools import load_yaml
23 from perguntations.testfactory import TestFactory, TestFactoryException 23 from perguntations.testfactory import TestFactory, TestFactoryException
24 import perguntations.test 24 import perguntations.test
25 -from perguntations.questions import QuestionFrom 25 +from perguntations.questions import question_from
26 26
27 logger = logging.getLogger(__name__) 27 logger = logging.getLogger(__name__)
28 28
@@ -129,32 +129,56 @@ class App(): @@ -129,32 +129,56 @@ class App():
129 # ------------------------------------------------------------------------ 129 # ------------------------------------------------------------------------
130 def _correct_tests(self): 130 def _correct_tests(self):
131 with self._db_session() as sess: 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 .filter(Test.ref == self.testfactory['ref'])\ 134 .filter(Test.ref == self.testfactory['ref'])\
134 .filter(Test.state == "SUBMITTED")\ 135 .filter(Test.state == "SUBMITTED")\
135 .all() 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 async def login(self, uid, try_pw, headers=None): 184 async def login(self, uid, try_pw, headers=None):
@@ -291,11 +315,9 @@ class App(): @@ -291,11 +315,9 @@ class App():
291 logger.info('"%s" grade = %g points.', uid, test['grade']) 315 logger.info('"%s" grade = %g points.', uid, test['grade'])
292 316
293 # --- save test in JSON format 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 fpath = path.join(test['answers_dir'], fname) 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 logger.info('"%s" saved JSON.', uid) 321 logger.info('"%s" saved JSON.', uid)
300 322
301 # --- insert test and questions into the database 323 # --- insert test and questions into the database
@@ -311,19 +333,19 @@ class App(): @@ -311,19 +333,19 @@ class App():
311 filename=fpath, 333 filename=fpath,
312 student_id=uid) 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 with self._db_session() as sess: 350 with self._db_session() as sess:
329 sess.add(test_row) 351 sess.add(test_row)
perguntations/questions.py
@@ -14,9 +14,9 @@ from typing import Any, Dict, NewType @@ -14,9 +14,9 @@ from typing import Any, Dict, NewType
14 import uuid 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 # this project 22 # this project
@@ -29,6 +29,8 @@ logger = logging.getLogger(__name__) @@ -29,6 +29,8 @@ logger = logging.getLogger(__name__)
29 QDict = NewType('QDict', Dict[str, Any]) 29 QDict = NewType('QDict', Dict[str, Any])
30 30
31 31
  32 +
  33 +
32 class QuestionException(Exception): 34 class QuestionException(Exception):
33 '''Exceptions raised in this module''' 35 '''Exceptions raised in this module'''
34 36
@@ -43,8 +45,13 @@ class Question(dict): @@ -43,8 +45,13 @@ class Question(dict):
43 for each student. 45 for each student.
44 Instances can shuffle options or automatically generate questions. 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 # add required keys if missing 56 # add required keys if missing
50 self.set_defaults(QDict({ 57 self.set_defaults(QDict({
@@ -89,9 +96,15 @@ class QuestionRadio(Question): @@ -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 try: 108 try:
96 nopts = len(self['options']) 109 nopts = len(self['options'])
97 except KeyError as exc: 110 except KeyError as exc:
@@ -218,8 +231,11 @@ class QuestionCheckbox(Question): @@ -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 try: 240 try:
225 nopts = len(self['options']) 241 nopts = len(self['options'])
@@ -340,9 +356,11 @@ class QuestionText(Question): @@ -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 self.set_defaults(QDict({ 364 self.set_defaults(QDict({
347 'text': '', 365 'text': '',
348 'correct': [], # no correct answers, always wrong 366 'correct': [], # no correct answers, always wrong
@@ -409,8 +427,11 @@ class QuestionTextRegex(Question): @@ -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 self.set_defaults(QDict({ 436 self.set_defaults(QDict({
416 'text': '', 437 'text': '',
@@ -422,26 +443,34 @@ class QuestionTextRegex(Question): @@ -422,26 +443,34 @@ class QuestionTextRegex(Question):
422 self['correct'] = [self['correct']] 443 self['correct'] = [self['correct']]
423 444
424 # converts patterns to compiled versions 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 def correct(self) -> None: 454 def correct(self) -> None:
434 super().correct() 455 super().correct()
435 if self['answer'] is not None: 456 if self['answer'] is not None:
436 - self['grade'] = 0.0  
437 for regex in self['correct']: 457 for regex in self['correct']:
438 try: 458 try:
439 - if regex.match(self['answer']): 459 + if re.fullmatch(regex, self['answer']):
440 self['grade'] = 1.0 460 self['grade'] = 1.0
441 return 461 return
442 except TypeError: 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,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 self.set_defaults(QDict({ 493 self.set_defaults(QDict({
462 'text': '', 494 'text': '',
@@ -516,8 +548,11 @@ class QuestionTextArea(Question): @@ -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 self.set_defaults(QDict({ 557 self.set_defaults(QDict({
523 'text': '', 558 'text': '',
@@ -720,8 +755,11 @@ class QuestionInformation(Question): @@ -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 self.set_defaults(QDict({ 763 self.set_defaults(QDict({
726 'text': '', 764 'text': '',
727 })) 765 }))
@@ -733,9 +771,8 @@ class QuestionInformation(Question): @@ -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 Converts a question specified in a dict into an instance of Question() 777 Converts a question specified in a dict into an instance of Question()
741 ''' 778 '''
@@ -836,6 +873,7 @@ class QFactory(): @@ -836,6 +873,7 @@ class QFactory():
836 qdict.update(out) 873 qdict.update(out)
837 874
838 question = question_from(qdict) # returns a Question instance 875 question = question_from(qdict) # returns a Question instance
  876 + question.gen()
839 return question 877 return question
840 878
841 # ------------------------------------------------------------------------ 879 # ------------------------------------------------------------------------
perguntations/templates/review-question.html
@@ -32,45 +32,46 @@ @@ -32,45 +32,46 @@
32 </p> 32 </p>
33 </div> <!-- card-body --> 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 {% end %} 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 <hr> 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 {% end %} 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 </div> <!-- card --> 75 </div> <!-- card -->
75 76
76 {% else %} 77 {% else %}
@@ -97,10 +98,10 @@ @@ -97,10 +98,10 @@
97 </small> 98 </small>
98 </p> 99 </p>
99 </div> <!-- card-body --> 100 </div> <!-- card-body -->
  101 +
100 <div class="card-footer"> 102 <div class="card-footer">
101 <p class="text-secondary"> 103 <p class="text-secondary">
102 <i class="fas fa-ban fa-3x" aria-hidden="true"></i> 104 <i class="fas fa-ban fa-3x" aria-hidden="true"></i>
103 - {{ round(q['grade'] * q['points'], 2) }} pontos<br>  
104 {{ md(q['comments']) }} 105 {{ md(q['comments']) }}
105 {% if q['solution'] %} 106 {% if q['solution'] %}
106 <hr> 107 <hr>
perguntations/test.py
@@ -4,8 +4,10 @@ Test - instances of this class are individual tests @@ -4,8 +4,10 @@ Test - instances of this class are individual tests
4 4
5 # python standard library 5 # python standard library
6 from datetime import datetime 6 from datetime import datetime
  7 +import json
7 import logging 8 import logging
8 from math import nan 9 from math import nan
  10 +from os import path
9 11
10 # Logger configuration 12 # Logger configuration
11 logger = logging.getLogger(__name__) 13 logger = logging.getLogger(__name__)
@@ -92,6 +94,12 @@ class Test(dict): @@ -92,6 +94,12 @@ class Test(dict):
92 self['grade'] = 0.0 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 def __str__(self) -> str: 103 def __str__(self) -> str:
96 return '\n'.join([f'{k}: {v}' for k,v in self.items()]) 104 return '\n'.join([f'{k}: {v}' for k,v in self.items()])
97 # return ('Test:\n' 105 # return ('Test:\n'