Commit ffb53a93a1ec9a7e45b3e69dbdfe9d05b822c1f8

Authored by Miguel Barão
1 parent 4c5146e6
Exists in master and in 1 other branch dev

First version that seems to be working after update to

sqlalchemy1.4.
Needs more testing.
@@ -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
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)
@@ -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={