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