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 | 2 | # BUGS |
3 | 3 | |
4 | 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 | 5 | - guardar testes em JSON assim que sao atribuidos aos alunos (ou guardados inicialmente com um certo nome, e atribuidos posteriormente ao aluno). |
7 | 6 | - cookies existe um perguntations_user e um user. De onde vem o user? |
8 | 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 | 431 | pode estar previamente preenchida como neste caso (use `answer: texto`). |
432 | 432 | correct: correct/correct-question.py |
433 | 433 | timeout: 5 |
434 | + tests_right: | |
435 | + - 'red green blue' | |
436 | + # tests_wrong: | |
437 | + # - 'blue gray yellow' | |
434 | 438 | |
435 | 439 | # --------------------------------------------------------------------------- |
436 | 440 | - type: information | ... | ... |
mypy.ini
1 | 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 | 97 | raise AppException(msg) from None |
98 | 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 | 116 | async def login(self, uid: str, password: str, headers: dict) -> Optional[str]: |
... | ... | @@ -114,6 +126,7 @@ class App(): |
114 | 126 | logger.warning('"%s" does not exist', uid) |
115 | 127 | return 'nonexistent' |
116 | 128 | |
129 | + | |
117 | 130 | if uid != '0' and self._students[uid]['state'] != 'allowed': |
118 | 131 | logger.warning('"%s" login not allowed', uid) |
119 | 132 | return 'not allowed' |
... | ... | @@ -127,16 +140,16 @@ class App(): |
127 | 140 | # success |
128 | 141 | if uid == '0': |
129 | 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 | 155 | async def set_password(self, uid: str, password: str) -> None: |
... | ... | @@ -151,10 +164,14 @@ class App(): |
151 | 164 | # ------------------------------------------------------------------------ |
152 | 165 | def logout(self, uid: str) -> None: |
153 | 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 | 177 | def _make_test_factory(self, filename: str) -> None: |
... | ... | @@ -187,9 +204,12 @@ class App(): |
187 | 204 | ans is a dictionary {question_index: answer, ...} with the answers for |
188 | 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 | 211 | # --- submit answers and correct test |
212 | + logger.info('"%s" submitted %d answers.', uid, len(ans)) | |
193 | 213 | test = self._students[uid]['test'] |
194 | 214 | test.submit(ans) |
195 | 215 | |
... | ... | @@ -236,7 +256,6 @@ class App(): |
236 | 256 | logger.info('"%s" database updated.', uid) |
237 | 257 | |
238 | 258 | # # ------------------------------------------------------------------------ |
239 | - # FIXME not working | |
240 | 259 | # def _correct_tests(self): |
241 | 260 | # with Session(self._engine, future=True) as session: |
242 | 261 | # # Find which tests have to be corrected |
... | ... | @@ -325,15 +344,15 @@ class App(): |
325 | 344 | # return test |
326 | 345 | |
327 | 346 | # ------------------------------------------------------------------------ |
328 | - def event_test(self, uid, cmd, value): | |
347 | + def register_event(self, uid, cmd, value): | |
329 | 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 | 358 | # GETTERS |
... | ... | @@ -349,7 +368,7 @@ class App(): |
349 | 368 | |
350 | 369 | # ------------------------------------------------------------------------ |
351 | 370 | def get_test_config(self) -> dict: |
352 | - '''return brief test configuration''' | |
371 | + '''return brief test configuration to use as header in /admin''' | |
353 | 372 | return {'title': self._testfactory['title'], |
354 | 373 | 'ref': self._testfactory['ref'], |
355 | 374 | 'filename': self._testfactory['testfile'], |
... | ... | @@ -426,15 +445,14 @@ class App(): |
426 | 445 | |
427 | 446 | # ------------------------------------------------------------------------ |
428 | 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 | 449 | return [{ 'uid': uid, |
431 | 450 | 'name': student['name'], |
432 | 451 | 'allowed': student['state'] == 'allowed', |
433 | 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 | 456 | 'grades': self.get_grades(uid, self._testfactory['ref']) } |
439 | 457 | for uid, student in self._students.items()] |
440 | 458 | |
... | ... | @@ -508,7 +526,7 @@ class App(): |
508 | 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 | 530 | '''insert new student into the database''' |
513 | 531 | with Session(self._engine, future=True) as session: |
514 | 532 | try: |
... | ... | @@ -519,11 +537,16 @@ class App(): |
519 | 537 | session.rollback() |
520 | 538 | return |
521 | 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 | 547 | def allow_from_list(self, filename: str) -> None: |
526 | 548 | '''allow students listed in text file (one number per line)''' |
549 | + # parse list of students to allow (one number per line) | |
527 | 550 | try: |
528 | 551 | with open(filename, 'r', encoding='utf-8') as file: |
529 | 552 | allowed = {line.strip() for line in file} |
... | ... | @@ -533,6 +556,7 @@ class App(): |
533 | 556 | logger.critical(error_msg) |
534 | 557 | raise AppException(error_msg) from exc |
535 | 558 | |
559 | + # update allowed state (missing are students allowed that don't exist) | |
536 | 560 | missing = 0 |
537 | 561 | for uid in allowed: |
538 | 562 | try: |
... | ... | @@ -545,20 +569,23 @@ class App(): |
545 | 569 | if missing: |
546 | 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 | 76 | |
77 | 77 | if debug: |
78 | 78 | level = 'DEBUG' |
79 | - # fmt = '%(asctime)s | %(levelname)-8s | %(module)-16s:%(lineno)4d | %(thread)d | %(message)s' | |
80 | 79 | fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s' |
81 | 80 | dateformat = '' |
82 | 81 | else: | ... | ... |
perguntations/models.py
... | ... | @@ -3,17 +3,15 @@ perguntations/models.py |
3 | 3 | SQLAlchemy ORM |
4 | 4 | ''' |
5 | 5 | |
6 | +from typing import Any | |
6 | 7 | |
7 | 8 | from sqlalchemy import Column, ForeignKey, Integer, Float, String |
8 | 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 | 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 | 137 | return '<table class="table table-sm"><thead class="thead-light">' \ |
138 | 138 | + header + '</thead><tbody>' + body + '</tbody></table>' |
139 | 139 | |
140 | - def image(self, src, title, alt): | |
140 | + def image(self, src, title, text): | |
141 | 141 | '''render image''' |
142 | - alt = mistune.escape(alt, quote=True) | |
142 | + alt = mistune.escape(text, quote=True) | |
143 | 143 | if title is not None: |
144 | 144 | if title: # not empty string, show as caption |
145 | 145 | title = mistune.escape(title, quote=True) | ... | ... |
perguntations/questions.py
... | ... | @@ -115,8 +115,7 @@ class QuestionRadio(Question): |
115 | 115 | # e.g. correct: 2 --> correct: [0,0,1,0,0] |
116 | 116 | if isinstance(self['correct'], int): |
117 | 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 | 119 | logger.error(msg) |
121 | 120 | raise QuestionException(msg) |
122 | 121 | |
... | ... | @@ -126,8 +125,7 @@ class QuestionRadio(Question): |
126 | 125 | elif isinstance(self['correct'], list): |
127 | 126 | # must match number of options |
128 | 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 | 129 | logger.error(msg) |
132 | 130 | raise QuestionException(msg) |
133 | 131 | |
... | ... | @@ -135,23 +133,20 @@ class QuestionRadio(Question): |
135 | 133 | try: |
136 | 134 | self['correct'] = [float(x) for x in self['correct']] |
137 | 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 | 137 | logger.error(msg) |
141 | 138 | raise QuestionException(msg) from exc |
142 | 139 | |
143 | 140 | # check grade boundaries |
144 | 141 | if self['discount'] and not all(0.0 <= x <= 1.0 |
145 | 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 | 144 | logger.error(msg) |
149 | 145 | raise QuestionException(msg) |
150 | 146 | |
151 | 147 | # at least one correct option |
152 | 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 | 150 | logger.error(msg) |
156 | 151 | raise QuestionException(msg) |
157 | 152 | |
... | ... | @@ -678,7 +673,7 @@ class QFactory(): |
678 | 673 | # which will print a valid question in yaml format to stdout. This |
679 | 674 | # output is then yaml parsed into a dictionary `q`. |
680 | 675 | if qdict['type'] == 'generator': |
681 | - logger.debug(' \\_ Running "%s".', qdict['script']) | |
676 | + logger.debug(' \\_ Running "%s"', qdict['script']) | |
682 | 677 | qdict.setdefault('args', []) |
683 | 678 | qdict.setdefault('stdin', '') |
684 | 679 | script = path.join(qdict['path'], qdict['script']) | ... | ... |
perguntations/serve.py
... | ... | @@ -17,6 +17,7 @@ import re |
17 | 17 | import signal |
18 | 18 | import sys |
19 | 19 | from timeit import default_timer as timer |
20 | +from typing import Dict, Tuple | |
20 | 21 | import uuid |
21 | 22 | |
22 | 23 | # user installed libraries |
... | ... | @@ -67,8 +68,8 @@ def admin_only(func): |
67 | 68 | ''' |
68 | 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 | 74 | @functools.wraps(func) |
74 | 75 | async def wrapper(self, *args, **kwargs): |
... | ... | @@ -111,7 +112,6 @@ class LoginHandler(BaseHandler): |
111 | 112 | _prefix = re.compile(r'[a-z]') |
112 | 113 | _error_msg = { |
113 | 114 | 'wrong_password': 'Senha errada', |
114 | - # 'already_online': 'Já está online, não pode entrar duas vezes', | |
115 | 115 | 'not allowed': 'Não está autorizado a fazer o teste', |
116 | 116 | 'nonexistent': 'Número de aluno inválido' |
117 | 117 | } |
... | ... | @@ -122,7 +122,6 @@ class LoginHandler(BaseHandler): |
122 | 122 | |
123 | 123 | async def post(self): |
124 | 124 | '''Authenticates student and login.''' |
125 | - # uid = self._prefix.sub('', self.get_body_argument('uid')) | |
126 | 125 | uid = self.get_body_argument('uid') |
127 | 126 | password = self.get_body_argument('pw') |
128 | 127 | headers = { |
... | ... | @@ -148,8 +147,8 @@ class LogoutHandler(BaseHandler): |
148 | 147 | @tornado.web.authenticated |
149 | 148 | def get(self): |
150 | 149 | '''Logs out a user.''' |
151 | - self.clear_cookie('perguntations_user') | |
152 | 150 | self.testapp.logout(self.current_user) |
151 | + self.clear_cookie('perguntations_user') | |
153 | 152 | self.render('login.html', error='') |
154 | 153 | |
155 | 154 | |
... | ... | @@ -159,7 +158,7 @@ class LogoutHandler(BaseHandler): |
159 | 158 | # pylint: disable=abstract-method |
160 | 159 | class RootHandler(BaseHandler): |
161 | 160 | ''' |
162 | - Generates test to student. | |
161 | + Presents test to student. | |
163 | 162 | Receives answers, corrects the test and sends back the grade. |
164 | 163 | Redirects user 0 to /admin. |
165 | 164 | ''' |
... | ... | @@ -172,7 +171,6 @@ class RootHandler(BaseHandler): |
172 | 171 | 'text-regex': 'question-text.html', |
173 | 172 | 'numeric-interval': 'question-text.html', |
174 | 173 | 'textarea': 'question-textarea.html', |
175 | - 'code': 'question-textarea.html', | |
176 | 174 | # -- information panels -- |
177 | 175 | 'information': 'question-information.html', |
178 | 176 | 'success': 'question-information.html', |
... | ... | @@ -188,19 +186,16 @@ class RootHandler(BaseHandler): |
188 | 186 | Sends test to student or redirects 0 to admin page. |
189 | 187 | Multiple calls to this function will return the same test. |
190 | 188 | ''' |
191 | - | |
192 | 189 | uid = self.current_user |
193 | 190 | logger.debug('"%s" GET /', uid) |
194 | 191 | |
195 | 192 | if uid == '0': |
196 | 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 | 200 | # --- POST |
206 | 201 | @tornado.web.authenticated |
... | ... | @@ -210,8 +205,8 @@ class RootHandler(BaseHandler): |
210 | 205 | renders the grade. |
211 | 206 | |
212 | 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 | 211 | starttime = timer() # performance timer |
217 | 212 | |
... | ... | @@ -226,17 +221,14 @@ class RootHandler(BaseHandler): |
226 | 221 | ans = {} |
227 | 222 | for i, question in enumerate(test['questions']): |
228 | 223 | qid = str(i) |
229 | - if 'answered-' + qid in self.request.arguments: | |
224 | + if f'answered-{qid}' in self.request.arguments: | |
230 | 225 | ans[i] = self.get_body_arguments(qid) |
231 | 226 | |
232 | 227 | # remove enclosing list in some question types |
233 | 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 | 230 | elif question['type'] in ('text', 'text-regex', 'textarea', |
239 | - 'numeric-interval', 'code'): | |
231 | + 'numeric-interval'): | |
240 | 232 | ans[i] = ans[i][0] |
241 | 233 | |
242 | 234 | # submit answered questions, correct |
... | ... | @@ -253,8 +245,8 @@ class RootHandler(BaseHandler): |
253 | 245 | # pylint: disable=abstract-method |
254 | 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 | 252 | @tornado.web.authenticated |
... | ... | @@ -262,8 +254,9 @@ class StudentWebservice(BaseHandler): |
262 | 254 | '''handle ajax post''' |
263 | 255 | uid = self.current_user |
264 | 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 | 280 | f'attachment; filename={test_ref}.csv') |
288 | 281 | self.write(data) |
289 | 282 | await self.flush() |
290 | - | |
291 | - if cmd == 'questionscsv': | |
283 | + elif cmd == 'questionscsv': | |
292 | 284 | test_ref, data = self.testapp.get_detailed_grades_csv() |
293 | 285 | self.set_header('Content-Type', 'text/csv') |
294 | 286 | self.set_header('content-Disposition', |
... | ... | @@ -344,11 +336,8 @@ class AdminHandler(BaseHandler): |
344 | 336 | await self.testapp.set_password(uid=value, pw='') |
345 | 337 | elif cmd == 'insert_student' and value is not None: |
346 | 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 | 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 | 354 | @tornado.web.authenticated |
366 | 355 | async def get(self): |
... | ... | @@ -390,10 +379,10 @@ class FileHandler(BaseHandler): |
390 | 379 | test = self.testapp.get_test(uid) |
391 | 380 | except KeyError: |
392 | 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 | 385 | for question in test['questions']: |
396 | - # search for the question that contains the image | |
397 | 386 | if question['ref'] == ref: |
398 | 387 | filepath = path.join(question['path'], 'public', image) |
399 | 388 | |
... | ... | @@ -402,13 +391,13 @@ class FileHandler(BaseHandler): |
402 | 391 | data = file.read() |
403 | 392 | except OSError: |
404 | 393 | logger.error('Error reading file "%s"', filepath) |
405 | - break | |
394 | + return | |
406 | 395 | self._filecache[(ref, image)] = data |
407 | 396 | self.write(data) |
408 | 397 | if content_type is not None: |
409 | 398 | self.set_header("Content-Type", content_type) |
410 | 399 | await self.flush() |
411 | - break | |
400 | + return | |
412 | 401 | |
413 | 402 | |
414 | 403 | # --- REVIEW ----------------------------------------------------------------- |
... | ... | @@ -425,7 +414,6 @@ class ReviewHandler(BaseHandler): |
425 | 414 | 'text-regex': 'review-question-text.html', |
426 | 415 | 'numeric-interval': 'review-question-text.html', |
427 | 416 | 'textarea': 'review-question-text.html', |
428 | - 'code': 'review-question-text.html', | |
429 | 417 | # -- information panels -- |
430 | 418 | 'information': 'review-question-information.html', |
431 | 419 | 'success': 'review-question-information.html', |
... | ... | @@ -460,7 +448,6 @@ class ReviewHandler(BaseHandler): |
460 | 448 | |
461 | 449 | uid = test['student'] |
462 | 450 | name = self.testapp.get_name(uid) |
463 | - | |
464 | 451 | self.render('review.html', t=test, uid=uid, name=name, |
465 | 452 | md=md_to_html, templ=self._templates) |
466 | 453 | ... | ... |
perguntations/static/js/admin.js
... | ... | @@ -117,16 +117,13 @@ $(document).ready(function() { |
117 | 117 | d = json.data[i]; |
118 | 118 | var uid = d['uid']; |
119 | 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 | 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 | 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 | 127 | var g = d['grades']; |
131 | 128 | |
132 | 129 | t[i] = []; |
... | ... | @@ -134,7 +131,7 @@ $(document).ready(function() { |
134 | 131 | t[i][1] = '<input type="checkbox" name="' + uid + '" value="true"' + checked + '> '; |
135 | 132 | t[i][2] = uid; |
136 | 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 | 136 | var gbar = ''; |
140 | 137 | for (var j=0; j < g.length; j++) | ... | ... |
perguntations/templates/admin.html
... | ... | @@ -53,8 +53,8 @@ |
53 | 53 | Acções |
54 | 54 | </a> |
55 | 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 | 58 | <a class="dropdown-item" href="#" id="allow_all">Autorizar todos</a> |
59 | 59 | <a class="dropdown-item" href="#" id="deny_all">Desautorizar todos</a> |
60 | 60 | <div class="dropdown-divider"></div> | ... | ... |
perguntations/test.py
... | ... | @@ -8,7 +8,7 @@ import json |
8 | 8 | import logging |
9 | 9 | from math import nan |
10 | 10 | |
11 | -# Logger configuration | |
11 | + | |
12 | 12 | logger = logging.getLogger(__name__) |
13 | 13 | |
14 | 14 | |
... | ... | @@ -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 | 23 | super().__init__(d) |
24 | 24 | self['grade'] = nan |
25 | 25 | self['comment'] = '' |
... | ... | @@ -46,14 +46,14 @@ class Test(dict): |
46 | 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 | 51 | Given a dictionary ans={'ref': 'some answer'} updates the answers of |
52 | 52 | multiple questions in the test. |
53 | 53 | Only affects the questions referred in the dictionary. |
54 | 54 | ''' |
55 | 55 | self['finish_time'] = datetime.now() |
56 | - for ref, ans in answers_dict.items(): | |
56 | + for ref, ans in answers.items(): | |
57 | 57 | self['questions'][ref].set_answer(ans) |
58 | 58 | self['state'] = 'SUBMITTED' |
59 | 59 | |
... | ... | @@ -93,9 +93,9 @@ class Test(dict): |
93 | 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 | 97 | '''save test in JSON format''' |
98 | - with open(pathfile, 'w') as file: | |
98 | + with open(filename, 'w', encoding='utf-8') as file: | |
99 | 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 | import random |
8 | 8 | import logging |
9 | 9 | import re |
10 | -from typing import Any, Dict | |
10 | +from typing import TypedDict | |
11 | 11 | |
12 | 12 | # this project |
13 | 13 | from perguntations.questions import QFactory, QuestionException, QDict |
... | ... | @@ -17,6 +17,10 @@ from perguntations.tools import load_yaml |
17 | 17 | # Logger configuration |
18 | 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 | 26 | class TestFactoryException(Exception): |
... | ... | @@ -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 | 41 | Loads configuration from yaml file, then overrides some configurations |
38 | 42 | using the conf argument. |
... | ... | @@ -47,7 +51,7 @@ class TestFactory(dict): |
47 | 51 | 'duration': 0, # 0=infinite |
48 | 52 | 'autosubmit': False, |
49 | 53 | 'autocorrect': True, |
50 | - 'debug': False, # FIXME not property of a test... | |
54 | + # 'debug': False, # FIXME not property of a test... | |
51 | 55 | 'show_ref': False, |
52 | 56 | }) |
53 | 57 | self.update(conf) |
... | ... | @@ -87,19 +91,19 @@ class TestFactory(dict): |
87 | 91 | logger.warning('Missing ref set to "%s"', question["ref"]) |
88 | 92 | |
89 | 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 | 97 | otherfile = path.join(other.question['path'], |
93 | 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 | 100 | raise TestFactoryException(msg) |
97 | 101 | |
98 | 102 | # make factory only for the questions used in the test |
99 | - if question['ref'] in qrefs: | |
103 | + if qref in qrefs: | |
100 | 104 | question.update(zip(('path', 'filename', 'index'), |
101 | 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 | 108 | qmissing = qrefs.difference(set(self['question_factory'].keys())) |
105 | 109 | if qmissing: |
... | ... | @@ -137,7 +141,7 @@ class TestFactory(dict): |
137 | 141 | '''Answers directory must be writable''' |
138 | 142 | testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') |
139 | 143 | try: |
140 | - with open(testfile, 'w') as file: | |
144 | + with open(testfile, 'w', encoding='utf-8') as file: | |
141 | 145 | file.write('You can safely remove this file.') |
142 | 146 | except OSError as exc: |
143 | 147 | msg = f'Cannot write answers to directory "{self["answers_dir"]}"' |
... | ... | @@ -223,44 +227,44 @@ class TestFactory(dict): |
223 | 227 | raise TestFactoryException(msg) from exc |
224 | 228 | else: |
225 | 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 | 270 | async def generate(self): |
... | ... | @@ -322,9 +326,8 @@ class TestFactory(dict): |
322 | 326 | inherit = {'ref', 'title', 'database', 'answers_dir', |
323 | 327 | 'questions_dir', 'files', |
324 | 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 | 332 | return Test({'questions': questions, **{k:self[k] for k in inherit}}) |
330 | 333 | |
... | ... | @@ -332,3 +335,42 @@ class TestFactory(dict): |
332 | 335 | def __repr__(self): |
333 | 336 | testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) |
334 | 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 | 35 | The script is run in another process but this function blocks waiting |
36 | 36 | for its termination. |
37 | 37 | ''' |
38 | - logger.info('run_script "%s"', script) | |
38 | + logger.debug('run_script "%s"', script) | |
39 | 39 | |
40 | 40 | output = None |
41 | 41 | script = path.expanduser(script) | ... | ... |
setup.py
... | ... | @@ -22,15 +22,14 @@ setup( |
22 | 22 | url="https://git.xdi.uevora.pt/mjsb/perguntations.git", |
23 | 23 | packages=find_packages(), |
24 | 24 | include_package_data=True, # install files from MANIFEST.in |
25 | - python_requires='>=3.7.*', | |
25 | + python_requires='>=3.8.*', | |
26 | 26 | install_requires=[ |
27 | - 'tornado>=6.0', | |
28 | - 'mistune', | |
27 | + 'tornado>=6.1', | |
28 | + 'mistune<2.0', | |
29 | 29 | 'pyyaml>=5.1', |
30 | 30 | 'pygments', |
31 | - # 'sqlalchemy', | |
32 | - 'sqlalchemy[asyncio]', | |
33 | - 'aiosqlite', | |
31 | + 'sqlalchemy>=1.4', | |
32 | + # 'sqlalchemy[asyncio,mypy]>=1.4', | |
34 | 33 | 'bcrypt>=3.1' |
35 | 34 | ], |
36 | 35 | entry_points={ | ... | ... |