Commit ffb53a93a1ec9a7e45b3e69dbdfe9d05b822c1f8
1 parent
4c5146e6
Exists in
master
and in
1 other branch
First version that seems to be working after update to
sqlalchemy1.4. Needs more testing.
Showing
15 changed files
with
243 additions
and
196 deletions
Show diff stats
BUGS.md
| @@ -2,7 +2,6 @@ | @@ -2,7 +2,6 @@ | ||
| 2 | # BUGS | 2 | # BUGS |
| 3 | 3 | ||
| 4 | - correct devia poder ser corrido mais que uma vez (por exemplo para alterar cotacoes, corrigir perguntas) | 4 | - correct devia poder ser corrido mais que uma vez (por exemplo para alterar cotacoes, corrigir perguntas) |
| 5 | -- nao esta a mostrar imagens?? internal server error? | ||
| 6 | - guardar testes em JSON assim que sao atribuidos aos alunos (ou guardados inicialmente com um certo nome, e atribuidos posteriormente ao aluno). | 5 | - guardar testes em JSON assim que sao atribuidos aos alunos (ou guardados inicialmente com um certo nome, e atribuidos posteriormente ao aluno). |
| 7 | - cookies existe um perguntations_user e um user. De onde vem o user? | 6 | - cookies existe um perguntations_user e um user. De onde vem o user? |
| 8 | - QuestionCode falta reportar nos comments os vários erros que podem ocorrer (timeout, etc) | 7 | - QuestionCode falta reportar nos comments os vários erros que podem ocorrer (timeout, etc) |
demo/questions/questions-tutorial.yaml
| @@ -431,6 +431,10 @@ | @@ -431,6 +431,10 @@ | ||
| 431 | pode estar previamente preenchida como neste caso (use `answer: texto`). | 431 | pode estar previamente preenchida como neste caso (use `answer: texto`). |
| 432 | correct: correct/correct-question.py | 432 | correct: correct/correct-question.py |
| 433 | timeout: 5 | 433 | timeout: 5 |
| 434 | + tests_right: | ||
| 435 | + - 'red green blue' | ||
| 436 | + # tests_wrong: | ||
| 437 | + # - 'blue gray yellow' | ||
| 434 | 438 | ||
| 435 | # --------------------------------------------------------------------------- | 439 | # --------------------------------------------------------------------------- |
| 436 | - type: information | 440 | - type: information |
mypy.ini
| 1 | [mypy] | 1 | [mypy] |
| 2 | -python_version = 3.8 | 2 | +python_version = 3.9 |
| 3 | 3 | ||
| 4 | -[mypy-setuptools.*] | ||
| 5 | -ignore_missing_imports = True | 4 | +; [mypy-setuptools.*] |
| 5 | +; ignore_missing_imports = True | ||
| 6 | 6 | ||
| 7 | -[mypy-sqlalchemy.*] | ||
| 8 | -ignore_missing_imports = True | 7 | +; [mypy-sqlalchemy.*] |
| 8 | +; ignore_missing_imports = True | ||
| 9 | 9 | ||
| 10 | -[mypy-pygments.*] | ||
| 11 | -ignore_missing_imports = True | 10 | +; [mypy-pygments.*] |
| 11 | +; ignore_missing_imports = True | ||
| 12 | 12 | ||
| 13 | -[mypy-mistune.*] | ||
| 14 | -ignore_missing_imports = True | 13 | +; [mypy-mistune.*] |
| 14 | +; ignore_missing_imports = True |
perguntations/app.py
| @@ -97,8 +97,20 @@ class App(): | @@ -97,8 +97,20 @@ class App(): | ||
| 97 | raise AppException(msg) from None | 97 | raise AppException(msg) from None |
| 98 | logger.info('Database has %d students.', len(dbstudents)) | 98 | logger.info('Database has %d students.', len(dbstudents)) |
| 99 | 99 | ||
| 100 | - self._students = {uid: {'name': name, 'state': 'offline', 'test': None} | ||
| 101 | - for uid, name in dbstudents} | 100 | + self._students = {uid: { |
| 101 | + 'name': name, | ||
| 102 | + 'state': 'offline', | ||
| 103 | + 'test': None, | ||
| 104 | + } for uid, name in dbstudents} | ||
| 105 | + | ||
| 106 | + # ------------------------------------------------------------------------ | ||
| 107 | + async def _assign_tests(self) -> None: | ||
| 108 | + '''Generate tests for all students that don't yet have a test''' | ||
| 109 | + logger.info('Generating tests...') | ||
| 110 | + for student in self._students.values(): | ||
| 111 | + if student.get('test', None) is None: | ||
| 112 | + student['test'] = await self._testfactory.generate() | ||
| 113 | + logger.info('Tests assigned to all students') | ||
| 102 | 114 | ||
| 103 | # ------------------------------------------------------------------------ | 115 | # ------------------------------------------------------------------------ |
| 104 | async def login(self, uid: str, password: str, headers: dict) -> Optional[str]: | 116 | async def login(self, uid: str, password: str, headers: dict) -> Optional[str]: |
| @@ -114,6 +126,7 @@ class App(): | @@ -114,6 +126,7 @@ class App(): | ||
| 114 | logger.warning('"%s" does not exist', uid) | 126 | logger.warning('"%s" does not exist', uid) |
| 115 | return 'nonexistent' | 127 | return 'nonexistent' |
| 116 | 128 | ||
| 129 | + | ||
| 117 | if uid != '0' and self._students[uid]['state'] != 'allowed': | 130 | if uid != '0' and self._students[uid]['state'] != 'allowed': |
| 118 | logger.warning('"%s" login not allowed', uid) | 131 | logger.warning('"%s" login not allowed', uid) |
| 119 | return 'not allowed' | 132 | return 'not allowed' |
| @@ -127,16 +140,16 @@ class App(): | @@ -127,16 +140,16 @@ class App(): | ||
| 127 | # success | 140 | # success |
| 128 | if uid == '0': | 141 | if uid == '0': |
| 129 | logger.info('Admin login from %s', headers['remote_ip']) | 142 | logger.info('Admin login from %s', headers['remote_ip']) |
| 130 | - return | ||
| 131 | - | ||
| 132 | - # FIXME this should probably be done elsewhere | ||
| 133 | - test = await self._testfactory.generate() | ||
| 134 | - test.start(uid) | ||
| 135 | - self._students[uid]['test'] = test | ||
| 136 | - | ||
| 137 | - self._students[uid]['state'] = 'waiting' | ||
| 138 | - self._students[uid]['headers'] = headers | ||
| 139 | - logger.info('"%s" login from %s.', uid, headers['remote_ip']) | 143 | + await self._assign_tests() |
| 144 | + else: | ||
| 145 | + student = self._students[uid] | ||
| 146 | + student['test'].start(uid) | ||
| 147 | + student['state'] = 'online' | ||
| 148 | + student['headers'] = headers | ||
| 149 | + student['unfocus'] = False | ||
| 150 | + student['area'] = 1.0 | ||
| 151 | + logger.info('"%s" login from %s.', uid, headers['remote_ip']) | ||
| 152 | + return None | ||
| 140 | 153 | ||
| 141 | # ------------------------------------------------------------------------ | 154 | # ------------------------------------------------------------------------ |
| 142 | async def set_password(self, uid: str, password: str) -> None: | 155 | async def set_password(self, uid: str, password: str) -> None: |
| @@ -151,10 +164,14 @@ class App(): | @@ -151,10 +164,14 @@ class App(): | ||
| 151 | # ------------------------------------------------------------------------ | 164 | # ------------------------------------------------------------------------ |
| 152 | def logout(self, uid: str) -> None: | 165 | def logout(self, uid: str) -> None: |
| 153 | '''student logout''' | 166 | '''student logout''' |
| 154 | - if uid in self._students: | ||
| 155 | - self._students[uid]['test'] = None | ||
| 156 | - self._students[uid]['state'] = 'offline' | ||
| 157 | - logger.info('"%s" logged out.', uid) | 167 | + student = self._students.get(uid, None) |
| 168 | + if student is not None: | ||
| 169 | + # student['test'] = None | ||
| 170 | + student['state'] = 'offline' | ||
| 171 | + student.pop('headers', None) | ||
| 172 | + student.pop('unfocus', None) | ||
| 173 | + student.pop('area', None) | ||
| 174 | + logger.info('"%s" logged out.', uid) | ||
| 158 | 175 | ||
| 159 | # ------------------------------------------------------------------------ | 176 | # ------------------------------------------------------------------------ |
| 160 | def _make_test_factory(self, filename: str) -> None: | 177 | def _make_test_factory(self, filename: str) -> None: |
| @@ -187,9 +204,12 @@ class App(): | @@ -187,9 +204,12 @@ class App(): | ||
| 187 | ans is a dictionary {question_index: answer, ...} with the answers for | 204 | ans is a dictionary {question_index: answer, ...} with the answers for |
| 188 | the complete test. For example: {0:'hello', 1:[1,2]} | 205 | the complete test. For example: {0:'hello', 1:[1,2]} |
| 189 | ''' | 206 | ''' |
| 190 | - logger.info('"%s" submitted %d answers.', uid, len(ans)) | 207 | + if self._students[uid]['state'] != 'online': |
| 208 | + logger.warning('"%s" INVALID SUBMISSION! STUDENT NOT ONLINE', uid) | ||
| 209 | + return | ||
| 191 | 210 | ||
| 192 | # --- submit answers and correct test | 211 | # --- submit answers and correct test |
| 212 | + logger.info('"%s" submitted %d answers.', uid, len(ans)) | ||
| 193 | test = self._students[uid]['test'] | 213 | test = self._students[uid]['test'] |
| 194 | test.submit(ans) | 214 | test.submit(ans) |
| 195 | 215 | ||
| @@ -236,7 +256,6 @@ class App(): | @@ -236,7 +256,6 @@ class App(): | ||
| 236 | logger.info('"%s" database updated.', uid) | 256 | logger.info('"%s" database updated.', uid) |
| 237 | 257 | ||
| 238 | # # ------------------------------------------------------------------------ | 258 | # # ------------------------------------------------------------------------ |
| 239 | - # FIXME not working | ||
| 240 | # def _correct_tests(self): | 259 | # def _correct_tests(self): |
| 241 | # with Session(self._engine, future=True) as session: | 260 | # with Session(self._engine, future=True) as session: |
| 242 | # # Find which tests have to be corrected | 261 | # # Find which tests have to be corrected |
| @@ -325,15 +344,15 @@ class App(): | @@ -325,15 +344,15 @@ class App(): | ||
| 325 | # return test | 344 | # return test |
| 326 | 345 | ||
| 327 | # ------------------------------------------------------------------------ | 346 | # ------------------------------------------------------------------------ |
| 328 | - def event_test(self, uid, cmd, value): | 347 | + def register_event(self, uid, cmd, value): |
| 329 | '''handles browser events the occur during the test''' | 348 | '''handles browser events the occur during the test''' |
| 330 | - # if cmd == 'focus': | ||
| 331 | - # if value: | ||
| 332 | - # self._focus_student(uid) | ||
| 333 | - # else: | ||
| 334 | - # self._unfocus_student(uid) | ||
| 335 | - # elif cmd == 'size': | ||
| 336 | - # self._set_screen_area(uid, value) | 349 | + if cmd == 'focus': |
| 350 | + if value: | ||
| 351 | + self._focus_student(uid) | ||
| 352 | + else: | ||
| 353 | + self._unfocus_student(uid) | ||
| 354 | + elif cmd == 'size': | ||
| 355 | + self._set_screen_area(uid, value) | ||
| 337 | 356 | ||
| 338 | # ======================================================================== | 357 | # ======================================================================== |
| 339 | # GETTERS | 358 | # GETTERS |
| @@ -349,7 +368,7 @@ class App(): | @@ -349,7 +368,7 @@ class App(): | ||
| 349 | 368 | ||
| 350 | # ------------------------------------------------------------------------ | 369 | # ------------------------------------------------------------------------ |
| 351 | def get_test_config(self) -> dict: | 370 | def get_test_config(self) -> dict: |
| 352 | - '''return brief test configuration''' | 371 | + '''return brief test configuration to use as header in /admin''' |
| 353 | return {'title': self._testfactory['title'], | 372 | return {'title': self._testfactory['title'], |
| 354 | 'ref': self._testfactory['ref'], | 373 | 'ref': self._testfactory['ref'], |
| 355 | 'filename': self._testfactory['testfile'], | 374 | 'filename': self._testfactory['testfile'], |
| @@ -426,15 +445,14 @@ class App(): | @@ -426,15 +445,14 @@ class App(): | ||
| 426 | 445 | ||
| 427 | # ------------------------------------------------------------------------ | 446 | # ------------------------------------------------------------------------ |
| 428 | def get_students_state(self) -> list: | 447 | def get_students_state(self) -> list: |
| 429 | - '''get list of states of every student''' | 448 | + '''get list of states of every student to show in /admin page''' |
| 430 | return [{ 'uid': uid, | 449 | return [{ 'uid': uid, |
| 431 | 'name': student['name'], | 450 | 'name': student['name'], |
| 432 | 'allowed': student['state'] == 'allowed', | 451 | 'allowed': student['state'] == 'allowed', |
| 433 | 'online': student['state'] == 'online', | 452 | 'online': student['state'] == 'online', |
| 434 | - # 'start_time': student.get('test', {}).get('start_time', ''), | ||
| 435 | - # 'password_defined': False, #pw != '', | ||
| 436 | - # 'unfocus': False, | ||
| 437 | - # 'area': '0.89', | 453 | + 'start_time': student.get('test', {}).get('start_time', ''), |
| 454 | + 'unfocus': student.get('unfocus', False), | ||
| 455 | + 'area': student.get('area', 1.0), | ||
| 438 | 'grades': self.get_grades(uid, self._testfactory['ref']) } | 456 | 'grades': self.get_grades(uid, self._testfactory['ref']) } |
| 439 | for uid, student in self._students.items()] | 457 | for uid, student in self._students.items()] |
| 440 | 458 | ||
| @@ -508,7 +526,7 @@ class App(): | @@ -508,7 +526,7 @@ class App(): | ||
| 508 | student['state'] = 'offline' | 526 | student['state'] = 'offline' |
| 509 | 527 | ||
| 510 | # ------------------------------------------------------------------------ | 528 | # ------------------------------------------------------------------------ |
| 511 | - def insert_new_student(self, uid: str, name: str) -> None: | 529 | + async def insert_new_student(self, uid: str, name: str) -> None: |
| 512 | '''insert new student into the database''' | 530 | '''insert new student into the database''' |
| 513 | with Session(self._engine, future=True) as session: | 531 | with Session(self._engine, future=True) as session: |
| 514 | try: | 532 | try: |
| @@ -519,11 +537,16 @@ class App(): | @@ -519,11 +537,16 @@ class App(): | ||
| 519 | session.rollback() | 537 | session.rollback() |
| 520 | return | 538 | return |
| 521 | logger.info('New student added: %s %s', uid, name) | 539 | logger.info('New student added: %s %s', uid, name) |
| 522 | - self._students[uid] = {'name': name, 'state': 'offline', 'test': None} | 540 | + self._students[uid] = { |
| 541 | + 'name': name, | ||
| 542 | + 'state': 'offline', | ||
| 543 | + 'test': await self._testfactory.generate(), | ||
| 544 | + } | ||
| 523 | 545 | ||
| 524 | # ------------------------------------------------------------------------ | 546 | # ------------------------------------------------------------------------ |
| 525 | def allow_from_list(self, filename: str) -> None: | 547 | def allow_from_list(self, filename: str) -> None: |
| 526 | '''allow students listed in text file (one number per line)''' | 548 | '''allow students listed in text file (one number per line)''' |
| 549 | + # parse list of students to allow (one number per line) | ||
| 527 | try: | 550 | try: |
| 528 | with open(filename, 'r', encoding='utf-8') as file: | 551 | with open(filename, 'r', encoding='utf-8') as file: |
| 529 | allowed = {line.strip() for line in file} | 552 | allowed = {line.strip() for line in file} |
| @@ -533,6 +556,7 @@ class App(): | @@ -533,6 +556,7 @@ class App(): | ||
| 533 | logger.critical(error_msg) | 556 | logger.critical(error_msg) |
| 534 | raise AppException(error_msg) from exc | 557 | raise AppException(error_msg) from exc |
| 535 | 558 | ||
| 559 | + # update allowed state (missing are students allowed that don't exist) | ||
| 536 | missing = 0 | 560 | missing = 0 |
| 537 | for uid in allowed: | 561 | for uid in allowed: |
| 538 | try: | 562 | try: |
| @@ -545,20 +569,23 @@ class App(): | @@ -545,20 +569,23 @@ class App(): | ||
| 545 | if missing: | 569 | if missing: |
| 546 | logger.warning(' %d missing!', missing) | 570 | logger.warning(' %d missing!', missing) |
| 547 | 571 | ||
| 548 | - # def _focus_student(self, uid): | ||
| 549 | - # '''set student in focus state''' | ||
| 550 | - # self.unfocus.discard(uid) | ||
| 551 | - # logger.info('"%s" focus', uid) | ||
| 552 | - | ||
| 553 | - # def _unfocus_student(self, uid): | ||
| 554 | - # '''set student in unfocus state''' | ||
| 555 | - # self.unfocus.add(uid) | ||
| 556 | - # logger.info('"%s" unfocus', uid) | ||
| 557 | - | ||
| 558 | - # def _set_screen_area(self, uid, sizes): | ||
| 559 | - # '''set current browser area as detected in resize event''' | ||
| 560 | - # scr_y, scr_x, win_y, win_x = sizes | ||
| 561 | - # area = win_x * win_y / (scr_x * scr_y) * 100 | ||
| 562 | - # self.area[uid] = area | ||
| 563 | - # logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', | ||
| 564 | - # uid, area, win_x, win_y, scr_x, scr_y) | 572 | + # ------------------------------------------------------------------------ |
| 573 | + def _focus_student(self, uid): | ||
| 574 | + '''set student in focus state''' | ||
| 575 | + self._students[uid]['unfocus'] = False | ||
| 576 | + logger.info('"%s" focus', uid) | ||
| 577 | + | ||
| 578 | + # ------------------------------------------------------------------------ | ||
| 579 | + def _unfocus_student(self, uid): | ||
| 580 | + '''set student in unfocus state''' | ||
| 581 | + self._students[uid]['unfocus'] = True | ||
| 582 | + logger.info('"%s" unfocus', uid) | ||
| 583 | + | ||
| 584 | + # ------------------------------------------------------------------------ | ||
| 585 | + def _set_screen_area(self, uid, sizes): | ||
| 586 | + '''set current browser area as detected in resize event''' | ||
| 587 | + scr_y, scr_x, win_y, win_x = sizes | ||
| 588 | + area = win_x * win_y / (scr_x * scr_y) * 100 | ||
| 589 | + self._students[uid]['area'] = area | ||
| 590 | + logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', | ||
| 591 | + uid, area, win_x, win_y, scr_x, scr_y) |
perguntations/main.py
| @@ -76,7 +76,6 @@ def get_logger_config(debug=False) -> dict: | @@ -76,7 +76,6 @@ def get_logger_config(debug=False) -> dict: | ||
| 76 | 76 | ||
| 77 | if debug: | 77 | if debug: |
| 78 | level = 'DEBUG' | 78 | level = 'DEBUG' |
| 79 | - # fmt = '%(asctime)s | %(levelname)-8s | %(module)-16s:%(lineno)4d | %(thread)d | %(message)s' | ||
| 80 | fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s' | 79 | fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s' |
| 81 | dateformat = '' | 80 | dateformat = '' |
| 82 | else: | 81 | else: |
perguntations/models.py
| @@ -3,17 +3,15 @@ perguntations/models.py | @@ -3,17 +3,15 @@ perguntations/models.py | ||
| 3 | SQLAlchemy ORM | 3 | SQLAlchemy ORM |
| 4 | ''' | 4 | ''' |
| 5 | 5 | ||
| 6 | +from typing import Any | ||
| 6 | 7 | ||
| 7 | from sqlalchemy import Column, ForeignKey, Integer, Float, String | 8 | from sqlalchemy import Column, ForeignKey, Integer, Float, String |
| 8 | from sqlalchemy.orm import declarative_base, relationship | 9 | from sqlalchemy.orm import declarative_base, relationship |
| 9 | 10 | ||
| 10 | 11 | ||
| 11 | -# ============================================================================ | ||
| 12 | -# Declare ORM | ||
| 13 | -# FIXME Any is a workaround for mypy static type checking (see https://github.com/python/mypy/issues/6372) | ||
| 14 | -from typing import Any | 12 | +# FIXME Any is a workaround for static type checking |
| 13 | +# (https://github.com/python/mypy/issues/6372) | ||
| 15 | Base: Any = declarative_base() | 14 | Base: Any = declarative_base() |
| 16 | -# Base = declarative_base() | ||
| 17 | 15 | ||
| 18 | 16 | ||
| 19 | # ---------------------------------------------------------------------------- | 17 | # ---------------------------------------------------------------------------- |
perguntations/parser_markdown.py
| @@ -137,9 +137,9 @@ class HighlightRenderer(mistune.Renderer): | @@ -137,9 +137,9 @@ class HighlightRenderer(mistune.Renderer): | ||
| 137 | return '<table class="table table-sm"><thead class="thead-light">' \ | 137 | return '<table class="table table-sm"><thead class="thead-light">' \ |
| 138 | + header + '</thead><tbody>' + body + '</tbody></table>' | 138 | + header + '</thead><tbody>' + body + '</tbody></table>' |
| 139 | 139 | ||
| 140 | - def image(self, src, title, alt): | 140 | + def image(self, src, title, text): |
| 141 | '''render image''' | 141 | '''render image''' |
| 142 | - alt = mistune.escape(alt, quote=True) | 142 | + alt = mistune.escape(text, quote=True) |
| 143 | if title is not None: | 143 | if title is not None: |
| 144 | if title: # not empty string, show as caption | 144 | if title: # not empty string, show as caption |
| 145 | title = mistune.escape(title, quote=True) | 145 | title = mistune.escape(title, quote=True) |
perguntations/questions.py
| @@ -115,8 +115,7 @@ class QuestionRadio(Question): | @@ -115,8 +115,7 @@ class QuestionRadio(Question): | ||
| 115 | # e.g. correct: 2 --> correct: [0,0,1,0,0] | 115 | # e.g. correct: 2 --> correct: [0,0,1,0,0] |
| 116 | if isinstance(self['correct'], int): | 116 | if isinstance(self['correct'], int): |
| 117 | if not 0 <= self['correct'] < nopts: | 117 | if not 0 <= self['correct'] < nopts: |
| 118 | - msg = (f'`correct` out of range 0..{nopts-1}. ' | ||
| 119 | - f'In question "{self["ref"]}"') | 118 | + msg = f'"{self["ref"]}": correct out of range 0..{nopts-1}' |
| 120 | logger.error(msg) | 119 | logger.error(msg) |
| 121 | raise QuestionException(msg) | 120 | raise QuestionException(msg) |
| 122 | 121 | ||
| @@ -126,8 +125,7 @@ class QuestionRadio(Question): | @@ -126,8 +125,7 @@ class QuestionRadio(Question): | ||
| 126 | elif isinstance(self['correct'], list): | 125 | elif isinstance(self['correct'], list): |
| 127 | # must match number of options | 126 | # must match number of options |
| 128 | if len(self['correct']) != nopts: | 127 | if len(self['correct']) != nopts: |
| 129 | - msg = (f'{nopts} options vs {len(self["correct"])} correct. ' | ||
| 130 | - f'In question "{self["ref"]}"') | 128 | + msg = f'"{self["ref"]}": number of options/correct mismatch' |
| 131 | logger.error(msg) | 129 | logger.error(msg) |
| 132 | raise QuestionException(msg) | 130 | raise QuestionException(msg) |
| 133 | 131 | ||
| @@ -135,23 +133,20 @@ class QuestionRadio(Question): | @@ -135,23 +133,20 @@ class QuestionRadio(Question): | ||
| 135 | try: | 133 | try: |
| 136 | self['correct'] = [float(x) for x in self['correct']] | 134 | self['correct'] = [float(x) for x in self['correct']] |
| 137 | except (ValueError, TypeError) as exc: | 135 | except (ValueError, TypeError) as exc: |
| 138 | - msg = ('`correct` must be list of numbers or booleans.' | ||
| 139 | - f'In "{self["ref"]}"') | 136 | + msg = f'"{self["ref"]}": correct must contain floats or bools' |
| 140 | logger.error(msg) | 137 | logger.error(msg) |
| 141 | raise QuestionException(msg) from exc | 138 | raise QuestionException(msg) from exc |
| 142 | 139 | ||
| 143 | # check grade boundaries | 140 | # check grade boundaries |
| 144 | if self['discount'] and not all(0.0 <= x <= 1.0 | 141 | if self['discount'] and not all(0.0 <= x <= 1.0 |
| 145 | for x in self['correct']): | 142 | for x in self['correct']): |
| 146 | - msg = ('`correct` values must be in the interval [0.0, 1.0]. ' | ||
| 147 | - f'In "{self["ref"]}"') | 143 | + msg = f'"{self["ref"]}": correct must be in [0.0, 1.0]' |
| 148 | logger.error(msg) | 144 | logger.error(msg) |
| 149 | raise QuestionException(msg) | 145 | raise QuestionException(msg) |
| 150 | 146 | ||
| 151 | # at least one correct option | 147 | # at least one correct option |
| 152 | if all(x < 1.0 for x in self['correct']): | 148 | if all(x < 1.0 for x in self['correct']): |
| 153 | - msg = ('At least one correct option is required. ' | ||
| 154 | - f'In "{self["ref"]}"') | 149 | + msg = f'"{self["ref"]}": has no correct options' |
| 155 | logger.error(msg) | 150 | logger.error(msg) |
| 156 | raise QuestionException(msg) | 151 | raise QuestionException(msg) |
| 157 | 152 | ||
| @@ -678,7 +673,7 @@ class QFactory(): | @@ -678,7 +673,7 @@ class QFactory(): | ||
| 678 | # which will print a valid question in yaml format to stdout. This | 673 | # which will print a valid question in yaml format to stdout. This |
| 679 | # output is then yaml parsed into a dictionary `q`. | 674 | # output is then yaml parsed into a dictionary `q`. |
| 680 | if qdict['type'] == 'generator': | 675 | if qdict['type'] == 'generator': |
| 681 | - logger.debug(' \\_ Running "%s".', qdict['script']) | 676 | + logger.debug(' \\_ Running "%s"', qdict['script']) |
| 682 | qdict.setdefault('args', []) | 677 | qdict.setdefault('args', []) |
| 683 | qdict.setdefault('stdin', '') | 678 | qdict.setdefault('stdin', '') |
| 684 | script = path.join(qdict['path'], qdict['script']) | 679 | script = path.join(qdict['path'], qdict['script']) |
perguntations/serve.py
| @@ -17,6 +17,7 @@ import re | @@ -17,6 +17,7 @@ import re | ||
| 17 | import signal | 17 | import signal |
| 18 | import sys | 18 | import sys |
| 19 | from timeit import default_timer as timer | 19 | from timeit import default_timer as timer |
| 20 | +from typing import Dict, Tuple | ||
| 20 | import uuid | 21 | import uuid |
| 21 | 22 | ||
| 22 | # user installed libraries | 23 | # user installed libraries |
| @@ -67,8 +68,8 @@ def admin_only(func): | @@ -67,8 +68,8 @@ def admin_only(func): | ||
| 67 | ''' | 68 | ''' |
| 68 | Decorator to restrict access to the administrator: | 69 | Decorator to restrict access to the administrator: |
| 69 | 70 | ||
| 70 | - @admin_only | ||
| 71 | - def get(self): ... | 71 | + @admin_only |
| 72 | + def get(self): | ||
| 72 | ''' | 73 | ''' |
| 73 | @functools.wraps(func) | 74 | @functools.wraps(func) |
| 74 | async def wrapper(self, *args, **kwargs): | 75 | async def wrapper(self, *args, **kwargs): |
| @@ -111,7 +112,6 @@ class LoginHandler(BaseHandler): | @@ -111,7 +112,6 @@ class LoginHandler(BaseHandler): | ||
| 111 | _prefix = re.compile(r'[a-z]') | 112 | _prefix = re.compile(r'[a-z]') |
| 112 | _error_msg = { | 113 | _error_msg = { |
| 113 | 'wrong_password': 'Senha errada', | 114 | 'wrong_password': 'Senha errada', |
| 114 | - # 'already_online': 'Já está online, não pode entrar duas vezes', | ||
| 115 | 'not allowed': 'Não está autorizado a fazer o teste', | 115 | 'not allowed': 'Não está autorizado a fazer o teste', |
| 116 | 'nonexistent': 'Número de aluno inválido' | 116 | 'nonexistent': 'Número de aluno inválido' |
| 117 | } | 117 | } |
| @@ -122,7 +122,6 @@ class LoginHandler(BaseHandler): | @@ -122,7 +122,6 @@ class LoginHandler(BaseHandler): | ||
| 122 | 122 | ||
| 123 | async def post(self): | 123 | async def post(self): |
| 124 | '''Authenticates student and login.''' | 124 | '''Authenticates student and login.''' |
| 125 | - # uid = self._prefix.sub('', self.get_body_argument('uid')) | ||
| 126 | uid = self.get_body_argument('uid') | 125 | uid = self.get_body_argument('uid') |
| 127 | password = self.get_body_argument('pw') | 126 | password = self.get_body_argument('pw') |
| 128 | headers = { | 127 | headers = { |
| @@ -148,8 +147,8 @@ class LogoutHandler(BaseHandler): | @@ -148,8 +147,8 @@ class LogoutHandler(BaseHandler): | ||
| 148 | @tornado.web.authenticated | 147 | @tornado.web.authenticated |
| 149 | def get(self): | 148 | def get(self): |
| 150 | '''Logs out a user.''' | 149 | '''Logs out a user.''' |
| 151 | - self.clear_cookie('perguntations_user') | ||
| 152 | self.testapp.logout(self.current_user) | 150 | self.testapp.logout(self.current_user) |
| 151 | + self.clear_cookie('perguntations_user') | ||
| 153 | self.render('login.html', error='') | 152 | self.render('login.html', error='') |
| 154 | 153 | ||
| 155 | 154 | ||
| @@ -159,7 +158,7 @@ class LogoutHandler(BaseHandler): | @@ -159,7 +158,7 @@ class LogoutHandler(BaseHandler): | ||
| 159 | # pylint: disable=abstract-method | 158 | # pylint: disable=abstract-method |
| 160 | class RootHandler(BaseHandler): | 159 | class RootHandler(BaseHandler): |
| 161 | ''' | 160 | ''' |
| 162 | - Generates test to student. | 161 | + Presents test to student. |
| 163 | Receives answers, corrects the test and sends back the grade. | 162 | Receives answers, corrects the test and sends back the grade. |
| 164 | Redirects user 0 to /admin. | 163 | Redirects user 0 to /admin. |
| 165 | ''' | 164 | ''' |
| @@ -172,7 +171,6 @@ class RootHandler(BaseHandler): | @@ -172,7 +171,6 @@ class RootHandler(BaseHandler): | ||
| 172 | 'text-regex': 'question-text.html', | 171 | 'text-regex': 'question-text.html', |
| 173 | 'numeric-interval': 'question-text.html', | 172 | 'numeric-interval': 'question-text.html', |
| 174 | 'textarea': 'question-textarea.html', | 173 | 'textarea': 'question-textarea.html', |
| 175 | - 'code': 'question-textarea.html', | ||
| 176 | # -- information panels -- | 174 | # -- information panels -- |
| 177 | 'information': 'question-information.html', | 175 | 'information': 'question-information.html', |
| 178 | 'success': 'question-information.html', | 176 | 'success': 'question-information.html', |
| @@ -188,19 +186,16 @@ class RootHandler(BaseHandler): | @@ -188,19 +186,16 @@ class RootHandler(BaseHandler): | ||
| 188 | Sends test to student or redirects 0 to admin page. | 186 | Sends test to student or redirects 0 to admin page. |
| 189 | Multiple calls to this function will return the same test. | 187 | Multiple calls to this function will return the same test. |
| 190 | ''' | 188 | ''' |
| 191 | - | ||
| 192 | uid = self.current_user | 189 | uid = self.current_user |
| 193 | logger.debug('"%s" GET /', uid) | 190 | logger.debug('"%s" GET /', uid) |
| 194 | 191 | ||
| 195 | if uid == '0': | 192 | if uid == '0': |
| 196 | self.redirect('/admin') | 193 | self.redirect('/admin') |
| 197 | - return | ||
| 198 | - | ||
| 199 | - test = self.testapp.get_test(uid) | ||
| 200 | - name = self.testapp.get_name(uid) | ||
| 201 | - self.render('test.html', | ||
| 202 | - t=test, uid=uid, name=name, md=md_to_html, templ=self._templates) | ||
| 203 | - | 194 | + else: |
| 195 | + test = self.testapp.get_test(uid) | ||
| 196 | + name = self.testapp.get_name(uid) | ||
| 197 | + self.render('test.html', t=test, uid=uid, name=name, md=md_to_html, | ||
| 198 | + templ=self._templates) | ||
| 204 | 199 | ||
| 205 | # --- POST | 200 | # --- POST |
| 206 | @tornado.web.authenticated | 201 | @tornado.web.authenticated |
| @@ -210,8 +205,8 @@ class RootHandler(BaseHandler): | @@ -210,8 +205,8 @@ class RootHandler(BaseHandler): | ||
| 210 | renders the grade. | 205 | renders the grade. |
| 211 | 206 | ||
| 212 | self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} | 207 | self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} |
| 213 | - builds dictionary ans={0: 'answer0', 1:, 'answer1', ...} | ||
| 214 | - unanswered questions not included. | 208 | + builds dictionary ans = {0: 'answer0', 1:, 'answer1', ...} |
| 209 | + unanswered questions are not included. | ||
| 215 | ''' | 210 | ''' |
| 216 | starttime = timer() # performance timer | 211 | starttime = timer() # performance timer |
| 217 | 212 | ||
| @@ -226,17 +221,14 @@ class RootHandler(BaseHandler): | @@ -226,17 +221,14 @@ class RootHandler(BaseHandler): | ||
| 226 | ans = {} | 221 | ans = {} |
| 227 | for i, question in enumerate(test['questions']): | 222 | for i, question in enumerate(test['questions']): |
| 228 | qid = str(i) | 223 | qid = str(i) |
| 229 | - if 'answered-' + qid in self.request.arguments: | 224 | + if f'answered-{qid}' in self.request.arguments: |
| 230 | ans[i] = self.get_body_arguments(qid) | 225 | ans[i] = self.get_body_arguments(qid) |
| 231 | 226 | ||
| 232 | # remove enclosing list in some question types | 227 | # remove enclosing list in some question types |
| 233 | if question['type'] == 'radio': | 228 | if question['type'] == 'radio': |
| 234 | - if not ans[i]: | ||
| 235 | - ans[i] = None | ||
| 236 | - else: | ||
| 237 | - ans[i] = ans[i][0] | 229 | + ans[i] = ans[i][0] if ans[i] else None |
| 238 | elif question['type'] in ('text', 'text-regex', 'textarea', | 230 | elif question['type'] in ('text', 'text-regex', 'textarea', |
| 239 | - 'numeric-interval', 'code'): | 231 | + 'numeric-interval'): |
| 240 | ans[i] = ans[i][0] | 232 | ans[i] = ans[i][0] |
| 241 | 233 | ||
| 242 | # submit answered questions, correct | 234 | # submit answered questions, correct |
| @@ -253,8 +245,8 @@ class RootHandler(BaseHandler): | @@ -253,8 +245,8 @@ class RootHandler(BaseHandler): | ||
| 253 | # pylint: disable=abstract-method | 245 | # pylint: disable=abstract-method |
| 254 | class StudentWebservice(BaseHandler): | 246 | class StudentWebservice(BaseHandler): |
| 255 | ''' | 247 | ''' |
| 256 | - Receive ajax from students in the test in response from focus, unfocus and | ||
| 257 | - resize events. | 248 | + Receive ajax from students during the test in response to the events |
| 249 | + focus, unfocus and resize. | ||
| 258 | ''' | 250 | ''' |
| 259 | 251 | ||
| 260 | @tornado.web.authenticated | 252 | @tornado.web.authenticated |
| @@ -262,8 +254,9 @@ class StudentWebservice(BaseHandler): | @@ -262,8 +254,9 @@ class StudentWebservice(BaseHandler): | ||
| 262 | '''handle ajax post''' | 254 | '''handle ajax post''' |
| 263 | uid = self.current_user | 255 | uid = self.current_user |
| 264 | cmd = self.get_body_argument('cmd', None) | 256 | cmd = self.get_body_argument('cmd', None) |
| 265 | - value = json.loads(self.get_body_argument('value', None)) | ||
| 266 | - self.testapp.event_test(uid, cmd, value) | 257 | + value = self.get_body_argument('value', None) |
| 258 | + if cmd is not None and value is not None: | ||
| 259 | + self.testapp.register_event(uid, cmd, json.loads(value)) | ||
| 267 | 260 | ||
| 268 | 261 | ||
| 269 | # ---------------------------------------------------------------------------- | 262 | # ---------------------------------------------------------------------------- |
| @@ -287,8 +280,7 @@ class AdminWebservice(BaseHandler): | @@ -287,8 +280,7 @@ class AdminWebservice(BaseHandler): | ||
| 287 | f'attachment; filename={test_ref}.csv') | 280 | f'attachment; filename={test_ref}.csv') |
| 288 | self.write(data) | 281 | self.write(data) |
| 289 | await self.flush() | 282 | await self.flush() |
| 290 | - | ||
| 291 | - if cmd == 'questionscsv': | 283 | + elif cmd == 'questionscsv': |
| 292 | test_ref, data = self.testapp.get_detailed_grades_csv() | 284 | test_ref, data = self.testapp.get_detailed_grades_csv() |
| 293 | self.set_header('Content-Type', 'text/csv') | 285 | self.set_header('Content-Type', 'text/csv') |
| 294 | self.set_header('content-Disposition', | 286 | self.set_header('content-Disposition', |
| @@ -344,11 +336,8 @@ class AdminHandler(BaseHandler): | @@ -344,11 +336,8 @@ class AdminHandler(BaseHandler): | ||
| 344 | await self.testapp.set_password(uid=value, pw='') | 336 | await self.testapp.set_password(uid=value, pw='') |
| 345 | elif cmd == 'insert_student' and value is not None: | 337 | elif cmd == 'insert_student' and value is not None: |
| 346 | student = json.loads(value) | 338 | student = json.loads(value) |
| 347 | - self.testapp.insert_new_student(uid=student['number'], | ||
| 348 | - name=student['name']) | ||
| 349 | - | ||
| 350 | - else: | ||
| 351 | - logger.error('Unknown command: "%s"', cmd) | 339 | + await self.testapp.insert_new_student(uid=student['number'], |
| 340 | + name=student['name']) | ||
| 352 | 341 | ||
| 353 | 342 | ||
| 354 | # ---------------------------------------------------------------------------- | 343 | # ---------------------------------------------------------------------------- |
| @@ -360,7 +349,7 @@ class FileHandler(BaseHandler): | @@ -360,7 +349,7 @@ class FileHandler(BaseHandler): | ||
| 360 | Handles static files from questions like images, etc. | 349 | Handles static files from questions like images, etc. |
| 361 | ''' | 350 | ''' |
| 362 | 351 | ||
| 363 | - _filecache = {} | 352 | + _filecache: Dict[Tuple[str, str], bytes] = {} |
| 364 | 353 | ||
| 365 | @tornado.web.authenticated | 354 | @tornado.web.authenticated |
| 366 | async def get(self): | 355 | async def get(self): |
| @@ -390,10 +379,10 @@ class FileHandler(BaseHandler): | @@ -390,10 +379,10 @@ class FileHandler(BaseHandler): | ||
| 390 | test = self.testapp.get_test(uid) | 379 | test = self.testapp.get_test(uid) |
| 391 | except KeyError: | 380 | except KeyError: |
| 392 | logger.warning('Could not get test to serve image file') | 381 | logger.warning('Could not get test to serve image file') |
| 393 | - raise tornado.web.HTTPError(404) # Not Found | 382 | + raise tornado.web.HTTPError(404) from None # Not Found |
| 394 | 383 | ||
| 384 | + # search for the question that contains the image | ||
| 395 | for question in test['questions']: | 385 | for question in test['questions']: |
| 396 | - # search for the question that contains the image | ||
| 397 | if question['ref'] == ref: | 386 | if question['ref'] == ref: |
| 398 | filepath = path.join(question['path'], 'public', image) | 387 | filepath = path.join(question['path'], 'public', image) |
| 399 | 388 | ||
| @@ -402,13 +391,13 @@ class FileHandler(BaseHandler): | @@ -402,13 +391,13 @@ class FileHandler(BaseHandler): | ||
| 402 | data = file.read() | 391 | data = file.read() |
| 403 | except OSError: | 392 | except OSError: |
| 404 | logger.error('Error reading file "%s"', filepath) | 393 | logger.error('Error reading file "%s"', filepath) |
| 405 | - break | 394 | + return |
| 406 | self._filecache[(ref, image)] = data | 395 | self._filecache[(ref, image)] = data |
| 407 | self.write(data) | 396 | self.write(data) |
| 408 | if content_type is not None: | 397 | if content_type is not None: |
| 409 | self.set_header("Content-Type", content_type) | 398 | self.set_header("Content-Type", content_type) |
| 410 | await self.flush() | 399 | await self.flush() |
| 411 | - break | 400 | + return |
| 412 | 401 | ||
| 413 | 402 | ||
| 414 | # --- REVIEW ----------------------------------------------------------------- | 403 | # --- REVIEW ----------------------------------------------------------------- |
| @@ -425,7 +414,6 @@ class ReviewHandler(BaseHandler): | @@ -425,7 +414,6 @@ class ReviewHandler(BaseHandler): | ||
| 425 | 'text-regex': 'review-question-text.html', | 414 | 'text-regex': 'review-question-text.html', |
| 426 | 'numeric-interval': 'review-question-text.html', | 415 | 'numeric-interval': 'review-question-text.html', |
| 427 | 'textarea': 'review-question-text.html', | 416 | 'textarea': 'review-question-text.html', |
| 428 | - 'code': 'review-question-text.html', | ||
| 429 | # -- information panels -- | 417 | # -- information panels -- |
| 430 | 'information': 'review-question-information.html', | 418 | 'information': 'review-question-information.html', |
| 431 | 'success': 'review-question-information.html', | 419 | 'success': 'review-question-information.html', |
| @@ -460,7 +448,6 @@ class ReviewHandler(BaseHandler): | @@ -460,7 +448,6 @@ class ReviewHandler(BaseHandler): | ||
| 460 | 448 | ||
| 461 | uid = test['student'] | 449 | uid = test['student'] |
| 462 | name = self.testapp.get_name(uid) | 450 | name = self.testapp.get_name(uid) |
| 463 | - | ||
| 464 | self.render('review.html', t=test, uid=uid, name=name, | 451 | self.render('review.html', t=test, uid=uid, name=name, |
| 465 | md=md_to_html, templ=self._templates) | 452 | md=md_to_html, templ=self._templates) |
| 466 | 453 |
perguntations/static/js/admin.js
| @@ -117,16 +117,13 @@ $(document).ready(function() { | @@ -117,16 +117,13 @@ $(document).ready(function() { | ||
| 117 | d = json.data[i]; | 117 | d = json.data[i]; |
| 118 | var uid = d['uid']; | 118 | var uid = d['uid']; |
| 119 | var checked = d['allowed'] ? 'checked' : ''; | 119 | var checked = d['allowed'] ? 'checked' : ''; |
| 120 | - var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : ''; | 120 | + // var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : ''; |
| 121 | var hora_inicio = d['start_time'] ? ' <span class="badge badge-success"><i class="fas fa-hourglass-start"></i> ' + d['start_time'].slice(11,16) + '</span>': ''; | 121 | var hora_inicio = d['start_time'] ? ' <span class="badge badge-success"><i class="fas fa-hourglass-start"></i> ' + d['start_time'].slice(11,16) + '</span>': ''; |
| 122 | var unfocus = d['unfocus'] ? ' <span class="badge badge-danger">unfocus</span>' : ''; | 122 | var unfocus = d['unfocus'] ? ' <span class="badge badge-danger">unfocus</span>' : ''; |
| 123 | - var area = ''; | ||
| 124 | - if (d['start_time'] ) { | ||
| 125 | - if (d['area'] > 75) | ||
| 126 | - area = ' <span class="badge badge-success"><i class="fas fa-desktop"></i> ' + Math.round(d['area']) + '%</span>'; | ||
| 127 | - else | ||
| 128 | - area = ' <span class="badge badge-danger"><i class="fas fa-desktop"></i> ' + Math.round(d['area']) + '%</span>'; | ||
| 129 | - }; | 123 | + if (d['area'] > 75) |
| 124 | + area = ' <span class="badge badge-success"><i class="fas fa-desktop"></i>' + Math.round(d['area']) + '%</span>'; | ||
| 125 | + else | ||
| 126 | + area = ' <span class="badge badge-danger"><i class="fas fa-desktop"></i> ' + Math.round(d['area']) + '%</span>'; | ||
| 130 | var g = d['grades']; | 127 | var g = d['grades']; |
| 131 | 128 | ||
| 132 | t[i] = []; | 129 | t[i] = []; |
| @@ -134,7 +131,7 @@ $(document).ready(function() { | @@ -134,7 +131,7 @@ $(document).ready(function() { | ||
| 134 | t[i][1] = '<input type="checkbox" name="' + uid + '" value="true"' + checked + '> '; | 131 | t[i][1] = '<input type="checkbox" name="' + uid + '" value="true"' + checked + '> '; |
| 135 | t[i][2] = uid; | 132 | t[i][2] = uid; |
| 136 | t[i][3] = d['name']; | 133 | t[i][3] = d['name']; |
| 137 | - t[i][4] = password_defined + hora_inicio + area + unfocus; | 134 | + t[i][4] = d['online'] ? hora_inicio + area + unfocus : ''; |
| 138 | 135 | ||
| 139 | var gbar = ''; | 136 | var gbar = ''; |
| 140 | for (var j=0; j < g.length; j++) | 137 | for (var j=0; j < g.length; j++) |
perguntations/templates/admin.html
| @@ -53,8 +53,8 @@ | @@ -53,8 +53,8 @@ | ||
| 53 | Acções | 53 | Acções |
| 54 | </a> | 54 | </a> |
| 55 | <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownAluno"> | 55 | <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownAluno"> |
| 56 | - <a class="dropdown-item" href="#" id="novo_aluno" data-toggle="modal" data-target="#novo_aluno_modal">Inserir novo aluno...</a> | ||
| 57 | - <a class="dropdown-item" href="#" id="reset_password_menu" data-toggle="modal" data-target="#reset_password_modal">Reset password do aluno...</a> | 56 | + <a class="dropdown-item" href="#" id="novo_aluno" data-toggle="modal" data-target="#novo_aluno_modal">Novo aluno...</a> |
| 57 | + <a class="dropdown-item" href="#" id="reset_password_menu" data-toggle="modal" data-target="#reset_password_modal">Limpar password...</a> | ||
| 58 | <a class="dropdown-item" href="#" id="allow_all">Autorizar todos</a> | 58 | <a class="dropdown-item" href="#" id="allow_all">Autorizar todos</a> |
| 59 | <a class="dropdown-item" href="#" id="deny_all">Desautorizar todos</a> | 59 | <a class="dropdown-item" href="#" id="deny_all">Desautorizar todos</a> |
| 60 | <div class="dropdown-divider"></div> | 60 | <div class="dropdown-divider"></div> |
perguntations/test.py
| @@ -8,7 +8,7 @@ import json | @@ -8,7 +8,7 @@ import json | ||
| 8 | import logging | 8 | import logging |
| 9 | from math import nan | 9 | from math import nan |
| 10 | 10 | ||
| 11 | -# Logger configuration | 11 | + |
| 12 | logger = logging.getLogger(__name__) | 12 | logger = logging.getLogger(__name__) |
| 13 | 13 | ||
| 14 | 14 | ||
| @@ -19,7 +19,7 @@ class Test(dict): | @@ -19,7 +19,7 @@ class Test(dict): | ||
| 19 | ''' | 19 | ''' |
| 20 | 20 | ||
| 21 | # ------------------------------------------------------------------------ | 21 | # ------------------------------------------------------------------------ |
| 22 | - def __init__(self, d): | 22 | + def __init__(self, d: dict): |
| 23 | super().__init__(d) | 23 | super().__init__(d) |
| 24 | self['grade'] = nan | 24 | self['grade'] = nan |
| 25 | self['comment'] = '' | 25 | self['comment'] = '' |
| @@ -46,14 +46,14 @@ class Test(dict): | @@ -46,14 +46,14 @@ class Test(dict): | ||
| 46 | self['questions'][ref].set_answer(ans) | 46 | self['questions'][ref].set_answer(ans) |
| 47 | 47 | ||
| 48 | # ------------------------------------------------------------------------ | 48 | # ------------------------------------------------------------------------ |
| 49 | - def submit(self, answers_dict) -> None: | 49 | + def submit(self, answers: dict) -> None: |
| 50 | ''' | 50 | ''' |
| 51 | Given a dictionary ans={'ref': 'some answer'} updates the answers of | 51 | Given a dictionary ans={'ref': 'some answer'} updates the answers of |
| 52 | multiple questions in the test. | 52 | multiple questions in the test. |
| 53 | Only affects the questions referred in the dictionary. | 53 | Only affects the questions referred in the dictionary. |
| 54 | ''' | 54 | ''' |
| 55 | self['finish_time'] = datetime.now() | 55 | self['finish_time'] = datetime.now() |
| 56 | - for ref, ans in answers_dict.items(): | 56 | + for ref, ans in answers.items(): |
| 57 | self['questions'][ref].set_answer(ans) | 57 | self['questions'][ref].set_answer(ans) |
| 58 | self['state'] = 'SUBMITTED' | 58 | self['state'] = 'SUBMITTED' |
| 59 | 59 | ||
| @@ -93,9 +93,9 @@ class Test(dict): | @@ -93,9 +93,9 @@ class Test(dict): | ||
| 93 | self['grade'] = 0.0 | 93 | self['grade'] = 0.0 |
| 94 | 94 | ||
| 95 | # ------------------------------------------------------------------------ | 95 | # ------------------------------------------------------------------------ |
| 96 | - def save_json(self, pathfile) -> None: | 96 | + def save_json(self, filename: str) -> None: |
| 97 | '''save test in JSON format''' | 97 | '''save test in JSON format''' |
| 98 | - with open(pathfile, 'w') as file: | 98 | + with open(filename, 'w', encoding='utf-8') as file: |
| 99 | json.dump(self, file, indent=2, default=str) # str for datetime | 99 | json.dump(self, file, indent=2, default=str) # str for datetime |
| 100 | 100 | ||
| 101 | # ------------------------------------------------------------------------ | 101 | # ------------------------------------------------------------------------ |
perguntations/testfactory.py
| @@ -7,7 +7,7 @@ from os import path | @@ -7,7 +7,7 @@ from os import path | ||
| 7 | import random | 7 | import random |
| 8 | import logging | 8 | import logging |
| 9 | import re | 9 | import re |
| 10 | -from typing import Any, Dict | 10 | +from typing import TypedDict |
| 11 | 11 | ||
| 12 | # this project | 12 | # this project |
| 13 | from perguntations.questions import QFactory, QuestionException, QDict | 13 | from perguntations.questions import QFactory, QuestionException, QDict |
| @@ -17,6 +17,10 @@ from perguntations.tools import load_yaml | @@ -17,6 +17,10 @@ from perguntations.tools import load_yaml | ||
| 17 | # Logger configuration | 17 | # Logger configuration |
| 18 | logger = logging.getLogger(__name__) | 18 | logger = logging.getLogger(__name__) |
| 19 | 19 | ||
| 20 | +ConfigDict = TypedDict('ConfigDict', { | ||
| 21 | + 'title': str | ||
| 22 | + # TODO add other fields | ||
| 23 | + }) | ||
| 20 | 24 | ||
| 21 | # ============================================================================ | 25 | # ============================================================================ |
| 22 | class TestFactoryException(Exception): | 26 | class TestFactoryException(Exception): |
| @@ -32,7 +36,7 @@ class TestFactory(dict): | @@ -32,7 +36,7 @@ class TestFactory(dict): | ||
| 32 | ''' | 36 | ''' |
| 33 | 37 | ||
| 34 | # ------------------------------------------------------------------------ | 38 | # ------------------------------------------------------------------------ |
| 35 | - def __init__(self, conf: Dict[str, Any]) -> None: | 39 | + def __init__(self, conf: ConfigDict) -> None: |
| 36 | ''' | 40 | ''' |
| 37 | Loads configuration from yaml file, then overrides some configurations | 41 | Loads configuration from yaml file, then overrides some configurations |
| 38 | using the conf argument. | 42 | using the conf argument. |
| @@ -47,7 +51,7 @@ class TestFactory(dict): | @@ -47,7 +51,7 @@ class TestFactory(dict): | ||
| 47 | 'duration': 0, # 0=infinite | 51 | 'duration': 0, # 0=infinite |
| 48 | 'autosubmit': False, | 52 | 'autosubmit': False, |
| 49 | 'autocorrect': True, | 53 | 'autocorrect': True, |
| 50 | - 'debug': False, # FIXME not property of a test... | 54 | + # 'debug': False, # FIXME not property of a test... |
| 51 | 'show_ref': False, | 55 | 'show_ref': False, |
| 52 | }) | 56 | }) |
| 53 | self.update(conf) | 57 | self.update(conf) |
| @@ -87,19 +91,19 @@ class TestFactory(dict): | @@ -87,19 +91,19 @@ class TestFactory(dict): | ||
| 87 | logger.warning('Missing ref set to "%s"', question["ref"]) | 91 | logger.warning('Missing ref set to "%s"', question["ref"]) |
| 88 | 92 | ||
| 89 | # check for duplicate refs | 93 | # check for duplicate refs |
| 90 | - if question['ref'] in self['question_factory']: | ||
| 91 | - other = self['question_factory'][question['ref']] | 94 | + qref = question['ref'] |
| 95 | + if qref in self['question_factory']: | ||
| 96 | + other = self['question_factory'][qref] | ||
| 92 | otherfile = path.join(other.question['path'], | 97 | otherfile = path.join(other.question['path'], |
| 93 | other.question['filename']) | 98 | other.question['filename']) |
| 94 | - msg = (f'Duplicate reference "{question["ref"]}" in files ' | ||
| 95 | - f'"{otherfile}" and "{fullpath}".') | 99 | + msg = f'Duplicate "{qref}" in {otherfile} and {fullpath}' |
| 96 | raise TestFactoryException(msg) | 100 | raise TestFactoryException(msg) |
| 97 | 101 | ||
| 98 | # make factory only for the questions used in the test | 102 | # make factory only for the questions used in the test |
| 99 | - if question['ref'] in qrefs: | 103 | + if qref in qrefs: |
| 100 | question.update(zip(('path', 'filename', 'index'), | 104 | question.update(zip(('path', 'filename', 'index'), |
| 101 | path.split(fullpath) + (i,))) | 105 | path.split(fullpath) + (i,))) |
| 102 | - self['question_factory'][question['ref']] = QFactory(QDict(question)) | 106 | + self['question_factory'][qref] = QFactory(QDict(question)) |
| 103 | 107 | ||
| 104 | qmissing = qrefs.difference(set(self['question_factory'].keys())) | 108 | qmissing = qrefs.difference(set(self['question_factory'].keys())) |
| 105 | if qmissing: | 109 | if qmissing: |
| @@ -137,7 +141,7 @@ class TestFactory(dict): | @@ -137,7 +141,7 @@ class TestFactory(dict): | ||
| 137 | '''Answers directory must be writable''' | 141 | '''Answers directory must be writable''' |
| 138 | testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') | 142 | testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') |
| 139 | try: | 143 | try: |
| 140 | - with open(testfile, 'w') as file: | 144 | + with open(testfile, 'w', encoding='utf-8') as file: |
| 141 | file.write('You can safely remove this file.') | 145 | file.write('You can safely remove this file.') |
| 142 | except OSError as exc: | 146 | except OSError as exc: |
| 143 | msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | 147 | msg = f'Cannot write answers to directory "{self["answers_dir"]}"' |
| @@ -223,44 +227,44 @@ class TestFactory(dict): | @@ -223,44 +227,44 @@ class TestFactory(dict): | ||
| 223 | raise TestFactoryException(msg) from exc | 227 | raise TestFactoryException(msg) from exc |
| 224 | else: | 228 | else: |
| 225 | logger.info('%4d. %s: Ok', i, qref) | 229 | logger.info('%4d. %s: Ok', i, qref) |
| 226 | - # logger.info(' generate Ok') | ||
| 227 | - | ||
| 228 | - if question['type'] in ('code', 'textarea'): | ||
| 229 | - if 'tests_right' in question: | ||
| 230 | - for tnum, right_answer in enumerate(question['tests_right']): | ||
| 231 | - try: | ||
| 232 | - question.set_answer(right_answer) | ||
| 233 | - question.correct() | ||
| 234 | - except Exception as exc: | ||
| 235 | - msg = f'Failed to correct "{qref}"' | ||
| 236 | - raise TestFactoryException(msg) from exc | ||
| 237 | - | ||
| 238 | - if question['grade'] == 1.0: | ||
| 239 | - logger.info(' test %i Ok', tnum) | ||
| 240 | - else: | ||
| 241 | - logger.error(' TEST %i IS WRONG!!!', tnum) | ||
| 242 | - elif 'tests_wrong' in question: | ||
| 243 | - for tnum, wrong_answer in enumerate(question['tests_wrong']): | ||
| 244 | - try: | ||
| 245 | - question.set_answer(wrong_answer) | ||
| 246 | - question.correct() | ||
| 247 | - except Exception as exc: | ||
| 248 | - msg = f'Failed to correct "{qref}"' | ||
| 249 | - raise TestFactoryException(msg) from exc | ||
| 250 | - | ||
| 251 | - if question['grade'] < 1.0: | ||
| 252 | - logger.info(' test %i Ok', tnum) | ||
| 253 | - else: | ||
| 254 | - logger.error(' TEST %i IS WRONG!!!', tnum) | ||
| 255 | - else: | ||
| 256 | - try: | ||
| 257 | - question.set_answer('') | ||
| 258 | - question.correct() | ||
| 259 | - except Exception as exc: | ||
| 260 | - msg = f'Failed to correct "{qref}"' | ||
| 261 | - raise TestFactoryException(msg) from exc | ||
| 262 | - else: | ||
| 263 | - logger.info(' correct Ok but no tests to run') | 230 | + |
| 231 | + if question['type'] == 'textarea': | ||
| 232 | + _runtests_textarea(qref, question) | ||
| 233 | + # if 'tests_right' in question: | ||
| 234 | + # for tnum, right_answer in enumerate(question['tests_right']): | ||
| 235 | + # try: | ||
| 236 | + # question.set_answer(right_answer) | ||
| 237 | + # question.correct() | ||
| 238 | + # except Exception as exc: | ||
| 239 | + # msg = f'Failed to correct "{qref}"' | ||
| 240 | + # raise TestFactoryException(msg) from exc | ||
| 241 | + | ||
| 242 | + # if question['grade'] == 1.0: | ||
| 243 | + # logger.info(' test %i Ok', tnum) | ||
| 244 | + # else: | ||
| 245 | + # logger.error(' TEST %i IS WRONG!!!', tnum) | ||
| 246 | + # elif 'tests_wrong' in question: | ||
| 247 | + # for tnum, wrong_answer in enumerate(question['tests_wrong']): | ||
| 248 | + # try: | ||
| 249 | + # question.set_answer(wrong_answer) | ||
| 250 | + # question.correct() | ||
| 251 | + # except Exception as exc: | ||
| 252 | + # msg = f'Failed to correct "{qref}"' | ||
| 253 | + # raise TestFactoryException(msg) from exc | ||
| 254 | + | ||
| 255 | + # if question['grade'] < 1.0: | ||
| 256 | + # logger.info(' test %i Ok', tnum) | ||
| 257 | + # else: | ||
| 258 | + # logger.error(' TEST %i IS WRONG!!!', tnum) | ||
| 259 | + # else: | ||
| 260 | + # try: | ||
| 261 | + # question.set_answer('') | ||
| 262 | + # question.correct() | ||
| 263 | + # except Exception as exc: | ||
| 264 | + # msg = f'Failed to correct "{qref}"' | ||
| 265 | + # raise TestFactoryException(msg) from exc | ||
| 266 | + # else: | ||
| 267 | + # logger.info(' correct Ok but no tests to run') | ||
| 264 | 268 | ||
| 265 | # ------------------------------------------------------------------------ | 269 | # ------------------------------------------------------------------------ |
| 266 | async def generate(self): | 270 | async def generate(self): |
| @@ -322,9 +326,8 @@ class TestFactory(dict): | @@ -322,9 +326,8 @@ class TestFactory(dict): | ||
| 322 | inherit = {'ref', 'title', 'database', 'answers_dir', | 326 | inherit = {'ref', 'title', 'database', 'answers_dir', |
| 323 | 'questions_dir', 'files', | 327 | 'questions_dir', 'files', |
| 324 | 'duration', 'autosubmit', 'autocorrect', | 328 | 'duration', 'autosubmit', 'autocorrect', |
| 325 | - 'scale', 'show_points', | ||
| 326 | - 'show_ref', 'debug', } | ||
| 327 | - # NOT INCLUDED: testfile, allow_all, review | 329 | + 'scale', 'show_points', 'show_ref'} |
| 330 | + # NOT INCLUDED: testfile, allow_all, review, debug | ||
| 328 | 331 | ||
| 329 | return Test({'questions': questions, **{k:self[k] for k in inherit}}) | 332 | return Test({'questions': questions, **{k:self[k] for k in inherit}}) |
| 330 | 333 | ||
| @@ -332,3 +335,42 @@ class TestFactory(dict): | @@ -332,3 +335,42 @@ class TestFactory(dict): | ||
| 332 | def __repr__(self): | 335 | def __repr__(self): |
| 333 | testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) | 336 | testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) |
| 334 | return 'TestFactory({\n' + testsettings + '\n})' | 337 | return 'TestFactory({\n' + testsettings + '\n})' |
| 338 | + | ||
| 339 | +# ============================================================================ | ||
| 340 | +def _runtests_textarea(qref, question): | ||
| 341 | + ''' | ||
| 342 | + Checks if correction script works and runs tests if available | ||
| 343 | + ''' | ||
| 344 | + try: | ||
| 345 | + question.set_answer('') | ||
| 346 | + question.correct() | ||
| 347 | + except Exception as exc: | ||
| 348 | + msg = f'Failed to correct "{qref}"' | ||
| 349 | + raise TestFactoryException(msg) from exc | ||
| 350 | + logger.info(' correction works') | ||
| 351 | + | ||
| 352 | + for tnum, right_answer in enumerate(question.get('tests_right', {})): | ||
| 353 | + try: | ||
| 354 | + question.set_answer(right_answer) | ||
| 355 | + question.correct() | ||
| 356 | + except Exception as exc: | ||
| 357 | + msg = f'Failed to correct "{qref}"' | ||
| 358 | + raise TestFactoryException(msg) from exc | ||
| 359 | + | ||
| 360 | + if question['grade'] == 1.0: | ||
| 361 | + logger.info(' tests_right[%i] Ok', tnum) | ||
| 362 | + else: | ||
| 363 | + logger.error(' tests_right[%i] FAILED!!!', tnum) | ||
| 364 | + | ||
| 365 | + for tnum, wrong_answer in enumerate(question.get('tests_wrong', {})): | ||
| 366 | + try: | ||
| 367 | + question.set_answer(wrong_answer) | ||
| 368 | + question.correct() | ||
| 369 | + except Exception as exc: | ||
| 370 | + msg = f'Failed to correct "{qref}"' | ||
| 371 | + raise TestFactoryException(msg) from exc | ||
| 372 | + | ||
| 373 | + if question['grade'] < 1.0: | ||
| 374 | + logger.info(' tests_wrong[%i] Ok', tnum) | ||
| 375 | + else: | ||
| 376 | + logger.error(' tests_wrong[%i] FAILED!!!', tnum) |
perguntations/tools.py
| @@ -35,7 +35,7 @@ def run_script(script: str, | @@ -35,7 +35,7 @@ def run_script(script: str, | ||
| 35 | The script is run in another process but this function blocks waiting | 35 | The script is run in another process but this function blocks waiting |
| 36 | for its termination. | 36 | for its termination. |
| 37 | ''' | 37 | ''' |
| 38 | - logger.info('run_script "%s"', script) | 38 | + logger.debug('run_script "%s"', script) |
| 39 | 39 | ||
| 40 | output = None | 40 | output = None |
| 41 | script = path.expanduser(script) | 41 | script = path.expanduser(script) |
setup.py
| @@ -22,15 +22,14 @@ setup( | @@ -22,15 +22,14 @@ setup( | ||
| 22 | url="https://git.xdi.uevora.pt/mjsb/perguntations.git", | 22 | url="https://git.xdi.uevora.pt/mjsb/perguntations.git", |
| 23 | packages=find_packages(), | 23 | packages=find_packages(), |
| 24 | include_package_data=True, # install files from MANIFEST.in | 24 | include_package_data=True, # install files from MANIFEST.in |
| 25 | - python_requires='>=3.7.*', | 25 | + python_requires='>=3.8.*', |
| 26 | install_requires=[ | 26 | install_requires=[ |
| 27 | - 'tornado>=6.0', | ||
| 28 | - 'mistune', | 27 | + 'tornado>=6.1', |
| 28 | + 'mistune<2.0', | ||
| 29 | 'pyyaml>=5.1', | 29 | 'pyyaml>=5.1', |
| 30 | 'pygments', | 30 | 'pygments', |
| 31 | - # 'sqlalchemy', | ||
| 32 | - 'sqlalchemy[asyncio]', | ||
| 33 | - 'aiosqlite', | 31 | + 'sqlalchemy>=1.4', |
| 32 | + # 'sqlalchemy[asyncio,mypy]>=1.4', | ||
| 34 | 'bcrypt>=3.1' | 33 | 'bcrypt>=3.1' |
| 35 | ], | 34 | ], |
| 36 | entry_points={ | 35 | entry_points={ |