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' | ... | ... |