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={ |