Commit 96ae3635ade8084928617a596601435ccfbe1776
1 parent
9f9c3faf
Exists in
master
and in
1 other branch
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__()
Showing
5 changed files
with
178 additions
and
109 deletions
Show diff stats
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' | ... | ... |