From ffb53a93a1ec9a7e45b3e69dbdfe9d05b822c1f8 Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Sat, 1 Jan 2022 20:51:07 +0000 Subject: [PATCH] First version that seems to be working after update to sqlalchemy1.4. Needs more testing. --- BUGS.md | 1 - demo/questions/questions-tutorial.yaml | 4 ++++ mypy.ini | 18 +++++++++--------- perguntations/app.py | 129 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------- perguntations/main.py | 1 - perguntations/models.py | 8 +++----- perguntations/parser_markdown.py | 4 ++-- perguntations/questions.py | 17 ++++++----------- perguntations/serve.py | 69 ++++++++++++++++++++++++++++----------------------------------------- perguntations/static/js/admin.js | 15 ++++++--------- perguntations/templates/admin.html | 4 ++-- perguntations/test.py | 12 ++++++------ perguntations/testfactory.py | 144 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------------------------------- perguntations/tools.py | 2 +- setup.py | 11 +++++------ 15 files changed, 243 insertions(+), 196 deletions(-) diff --git a/BUGS.md b/BUGS.md index 49e3913..2ac7692 100644 --- a/BUGS.md +++ b/BUGS.md @@ -2,7 +2,6 @@ # BUGS - correct devia poder ser corrido mais que uma vez (por exemplo para alterar cotacoes, corrigir perguntas) -- nao esta a mostrar imagens?? internal server error? - guardar testes em JSON assim que sao atribuidos aos alunos (ou guardados inicialmente com um certo nome, e atribuidos posteriormente ao aluno). - cookies existe um perguntations_user e um user. De onde vem o user? - QuestionCode falta reportar nos comments os vários erros que podem ocorrer (timeout, etc) diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml index e6292e8..4bc9897 100644 --- a/demo/questions/questions-tutorial.yaml +++ b/demo/questions/questions-tutorial.yaml @@ -431,6 +431,10 @@ pode estar previamente preenchida como neste caso (use `answer: texto`). correct: correct/correct-question.py timeout: 5 + tests_right: + - 'red green blue' + # tests_wrong: + # - 'blue gray yellow' # --------------------------------------------------------------------------- - type: information diff --git a/mypy.ini b/mypy.ini index ed65b3b..84e7e80 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,14 +1,14 @@ [mypy] -python_version = 3.8 +python_version = 3.9 -[mypy-setuptools.*] -ignore_missing_imports = True +; [mypy-setuptools.*] +; ignore_missing_imports = True -[mypy-sqlalchemy.*] -ignore_missing_imports = True +; [mypy-sqlalchemy.*] +; ignore_missing_imports = True -[mypy-pygments.*] -ignore_missing_imports = True +; [mypy-pygments.*] +; ignore_missing_imports = True -[mypy-mistune.*] -ignore_missing_imports = True +; [mypy-mistune.*] +; ignore_missing_imports = True diff --git a/perguntations/app.py b/perguntations/app.py index 82dab7e..c7d9f90 100644 --- a/perguntations/app.py +++ b/perguntations/app.py @@ -97,8 +97,20 @@ class App(): raise AppException(msg) from None logger.info('Database has %d students.', len(dbstudents)) - self._students = {uid: {'name': name, 'state': 'offline', 'test': None} - for uid, name in dbstudents} + self._students = {uid: { + 'name': name, + 'state': 'offline', + 'test': None, + } for uid, name in dbstudents} + + # ------------------------------------------------------------------------ + async def _assign_tests(self) -> None: + '''Generate tests for all students that don't yet have a test''' + logger.info('Generating tests...') + for student in self._students.values(): + if student.get('test', None) is None: + student['test'] = await self._testfactory.generate() + logger.info('Tests assigned to all students') # ------------------------------------------------------------------------ async def login(self, uid: str, password: str, headers: dict) -> Optional[str]: @@ -114,6 +126,7 @@ class App(): logger.warning('"%s" does not exist', uid) return 'nonexistent' + if uid != '0' and self._students[uid]['state'] != 'allowed': logger.warning('"%s" login not allowed', uid) return 'not allowed' @@ -127,16 +140,16 @@ class App(): # success if uid == '0': logger.info('Admin login from %s', headers['remote_ip']) - return - - # FIXME this should probably be done elsewhere - test = await self._testfactory.generate() - test.start(uid) - self._students[uid]['test'] = test - - self._students[uid]['state'] = 'waiting' - self._students[uid]['headers'] = headers - logger.info('"%s" login from %s.', uid, headers['remote_ip']) + await self._assign_tests() + else: + student = self._students[uid] + student['test'].start(uid) + student['state'] = 'online' + student['headers'] = headers + student['unfocus'] = False + student['area'] = 1.0 + logger.info('"%s" login from %s.', uid, headers['remote_ip']) + return None # ------------------------------------------------------------------------ async def set_password(self, uid: str, password: str) -> None: @@ -151,10 +164,14 @@ class App(): # ------------------------------------------------------------------------ def logout(self, uid: str) -> None: '''student logout''' - if uid in self._students: - self._students[uid]['test'] = None - self._students[uid]['state'] = 'offline' - logger.info('"%s" logged out.', uid) + student = self._students.get(uid, None) + if student is not None: + # student['test'] = None + student['state'] = 'offline' + student.pop('headers', None) + student.pop('unfocus', None) + student.pop('area', None) + logger.info('"%s" logged out.', uid) # ------------------------------------------------------------------------ def _make_test_factory(self, filename: str) -> None: @@ -187,9 +204,12 @@ class App(): ans is a dictionary {question_index: answer, ...} with the answers for the complete test. For example: {0:'hello', 1:[1,2]} ''' - logger.info('"%s" submitted %d answers.', uid, len(ans)) + if self._students[uid]['state'] != 'online': + logger.warning('"%s" INVALID SUBMISSION! STUDENT NOT ONLINE', uid) + return # --- submit answers and correct test + logger.info('"%s" submitted %d answers.', uid, len(ans)) test = self._students[uid]['test'] test.submit(ans) @@ -236,7 +256,6 @@ class App(): logger.info('"%s" database updated.', uid) # # ------------------------------------------------------------------------ - # FIXME not working # def _correct_tests(self): # with Session(self._engine, future=True) as session: # # Find which tests have to be corrected @@ -325,15 +344,15 @@ class App(): # return test # ------------------------------------------------------------------------ - def event_test(self, uid, cmd, value): + def register_event(self, uid, cmd, value): '''handles browser events the occur during the test''' - # if cmd == 'focus': - # if value: - # self._focus_student(uid) - # else: - # self._unfocus_student(uid) - # elif cmd == 'size': - # self._set_screen_area(uid, value) + if cmd == 'focus': + if value: + self._focus_student(uid) + else: + self._unfocus_student(uid) + elif cmd == 'size': + self._set_screen_area(uid, value) # ======================================================================== # GETTERS @@ -349,7 +368,7 @@ class App(): # ------------------------------------------------------------------------ def get_test_config(self) -> dict: - '''return brief test configuration''' + '''return brief test configuration to use as header in /admin''' return {'title': self._testfactory['title'], 'ref': self._testfactory['ref'], 'filename': self._testfactory['testfile'], @@ -426,15 +445,14 @@ class App(): # ------------------------------------------------------------------------ def get_students_state(self) -> list: - '''get list of states of every student''' + '''get list of states of every student to show in /admin page''' return [{ 'uid': uid, 'name': student['name'], 'allowed': student['state'] == 'allowed', 'online': student['state'] == 'online', - # 'start_time': student.get('test', {}).get('start_time', ''), - # 'password_defined': False, #pw != '', - # 'unfocus': False, - # 'area': '0.89', + 'start_time': student.get('test', {}).get('start_time', ''), + 'unfocus': student.get('unfocus', False), + 'area': student.get('area', 1.0), 'grades': self.get_grades(uid, self._testfactory['ref']) } for uid, student in self._students.items()] @@ -508,7 +526,7 @@ class App(): student['state'] = 'offline' # ------------------------------------------------------------------------ - def insert_new_student(self, uid: str, name: str) -> None: + async def insert_new_student(self, uid: str, name: str) -> None: '''insert new student into the database''' with Session(self._engine, future=True) as session: try: @@ -519,11 +537,16 @@ class App(): session.rollback() return logger.info('New student added: %s %s', uid, name) - self._students[uid] = {'name': name, 'state': 'offline', 'test': None} + self._students[uid] = { + 'name': name, + 'state': 'offline', + 'test': await self._testfactory.generate(), + } # ------------------------------------------------------------------------ def allow_from_list(self, filename: str) -> None: '''allow students listed in text file (one number per line)''' + # parse list of students to allow (one number per line) try: with open(filename, 'r', encoding='utf-8') as file: allowed = {line.strip() for line in file} @@ -533,6 +556,7 @@ class App(): logger.critical(error_msg) raise AppException(error_msg) from exc + # update allowed state (missing are students allowed that don't exist) missing = 0 for uid in allowed: try: @@ -545,20 +569,23 @@ class App(): if missing: logger.warning(' %d missing!', missing) - # def _focus_student(self, uid): - # '''set student in focus state''' - # self.unfocus.discard(uid) - # logger.info('"%s" focus', uid) - - # def _unfocus_student(self, uid): - # '''set student in unfocus state''' - # self.unfocus.add(uid) - # logger.info('"%s" unfocus', uid) - - # def _set_screen_area(self, uid, sizes): - # '''set current browser area as detected in resize event''' - # scr_y, scr_x, win_y, win_x = sizes - # area = win_x * win_y / (scr_x * scr_y) * 100 - # self.area[uid] = area - # logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', - # uid, area, win_x, win_y, scr_x, scr_y) + # ------------------------------------------------------------------------ + def _focus_student(self, uid): + '''set student in focus state''' + self._students[uid]['unfocus'] = False + logger.info('"%s" focus', uid) + + # ------------------------------------------------------------------------ + def _unfocus_student(self, uid): + '''set student in unfocus state''' + self._students[uid]['unfocus'] = True + logger.info('"%s" unfocus', uid) + + # ------------------------------------------------------------------------ + def _set_screen_area(self, uid, sizes): + '''set current browser area as detected in resize event''' + scr_y, scr_x, win_y, win_x = sizes + area = win_x * win_y / (scr_x * scr_y) * 100 + self._students[uid]['area'] = area + logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', + uid, area, win_x, win_y, scr_x, scr_y) diff --git a/perguntations/main.py b/perguntations/main.py index 67fbbf0..cf9c00c 100644 --- a/perguntations/main.py +++ b/perguntations/main.py @@ -76,7 +76,6 @@ def get_logger_config(debug=False) -> dict: if debug: level = 'DEBUG' - # fmt = '%(asctime)s | %(levelname)-8s | %(module)-16s:%(lineno)4d | %(thread)d | %(message)s' fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s' dateformat = '' else: diff --git a/perguntations/models.py b/perguntations/models.py index c28609e..d1c6c07 100644 --- a/perguntations/models.py +++ b/perguntations/models.py @@ -3,17 +3,15 @@ perguntations/models.py SQLAlchemy ORM ''' +from typing import Any from sqlalchemy import Column, ForeignKey, Integer, Float, String from sqlalchemy.orm import declarative_base, relationship -# ============================================================================ -# Declare ORM -# FIXME Any is a workaround for mypy static type checking (see https://github.com/python/mypy/issues/6372) -from typing import Any +# FIXME Any is a workaround for static type checking +# (https://github.com/python/mypy/issues/6372) Base: Any = declarative_base() -# Base = declarative_base() # ---------------------------------------------------------------------------- diff --git a/perguntations/parser_markdown.py b/perguntations/parser_markdown.py index e04dbbd..f2cd3c8 100644 --- a/perguntations/parser_markdown.py +++ b/perguntations/parser_markdown.py @@ -137,9 +137,9 @@ class HighlightRenderer(mistune.Renderer): return '' \ + header + '' + body + '
' - def image(self, src, title, alt): + def image(self, src, title, text): '''render image''' - alt = mistune.escape(alt, quote=True) + alt = mistune.escape(text, quote=True) if title is not None: if title: # not empty string, show as caption title = mistune.escape(title, quote=True) diff --git a/perguntations/questions.py b/perguntations/questions.py index 25664a6..57b7013 100644 --- a/perguntations/questions.py +++ b/perguntations/questions.py @@ -115,8 +115,7 @@ class QuestionRadio(Question): # e.g. correct: 2 --> correct: [0,0,1,0,0] if isinstance(self['correct'], int): if not 0 <= self['correct'] < nopts: - msg = (f'`correct` out of range 0..{nopts-1}. ' - f'In question "{self["ref"]}"') + msg = f'"{self["ref"]}": correct out of range 0..{nopts-1}' logger.error(msg) raise QuestionException(msg) @@ -126,8 +125,7 @@ class QuestionRadio(Question): elif isinstance(self['correct'], list): # must match number of options if len(self['correct']) != nopts: - msg = (f'{nopts} options vs {len(self["correct"])} correct. ' - f'In question "{self["ref"]}"') + msg = f'"{self["ref"]}": number of options/correct mismatch' logger.error(msg) raise QuestionException(msg) @@ -135,23 +133,20 @@ class QuestionRadio(Question): try: self['correct'] = [float(x) for x in self['correct']] except (ValueError, TypeError) as exc: - msg = ('`correct` must be list of numbers or booleans.' - f'In "{self["ref"]}"') + msg = f'"{self["ref"]}": correct must contain floats or bools' logger.error(msg) raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 for x in self['correct']): - msg = ('`correct` values must be in the interval [0.0, 1.0]. ' - f'In "{self["ref"]}"') + msg = f'"{self["ref"]}": correct must be in [0.0, 1.0]' logger.error(msg) raise QuestionException(msg) # at least one correct option if all(x < 1.0 for x in self['correct']): - msg = ('At least one correct option is required. ' - f'In "{self["ref"]}"') + msg = f'"{self["ref"]}": has no correct options' logger.error(msg) raise QuestionException(msg) @@ -678,7 +673,7 @@ class QFactory(): # which will print a valid question in yaml format to stdout. This # output is then yaml parsed into a dictionary `q`. if qdict['type'] == 'generator': - logger.debug(' \\_ Running "%s".', qdict['script']) + logger.debug(' \\_ Running "%s"', qdict['script']) qdict.setdefault('args', []) qdict.setdefault('stdin', '') script = path.join(qdict['path'], qdict['script']) diff --git a/perguntations/serve.py b/perguntations/serve.py index c277c1e..cfbbf8c 100644 --- a/perguntations/serve.py +++ b/perguntations/serve.py @@ -17,6 +17,7 @@ import re import signal import sys from timeit import default_timer as timer +from typing import Dict, Tuple import uuid # user installed libraries @@ -67,8 +68,8 @@ def admin_only(func): ''' Decorator to restrict access to the administrator: - @admin_only - def get(self): ... + @admin_only + def get(self): ''' @functools.wraps(func) async def wrapper(self, *args, **kwargs): @@ -111,7 +112,6 @@ class LoginHandler(BaseHandler): _prefix = re.compile(r'[a-z]') _error_msg = { 'wrong_password': 'Senha errada', - # 'already_online': 'Já está online, não pode entrar duas vezes', 'not allowed': 'Não está autorizado a fazer o teste', 'nonexistent': 'Número de aluno inválido' } @@ -122,7 +122,6 @@ class LoginHandler(BaseHandler): async def post(self): '''Authenticates student and login.''' - # uid = self._prefix.sub('', self.get_body_argument('uid')) uid = self.get_body_argument('uid') password = self.get_body_argument('pw') headers = { @@ -148,8 +147,8 @@ class LogoutHandler(BaseHandler): @tornado.web.authenticated def get(self): '''Logs out a user.''' - self.clear_cookie('perguntations_user') self.testapp.logout(self.current_user) + self.clear_cookie('perguntations_user') self.render('login.html', error='') @@ -159,7 +158,7 @@ class LogoutHandler(BaseHandler): # pylint: disable=abstract-method class RootHandler(BaseHandler): ''' - Generates test to student. + Presents test to student. Receives answers, corrects the test and sends back the grade. Redirects user 0 to /admin. ''' @@ -172,7 +171,6 @@ class RootHandler(BaseHandler): 'text-regex': 'question-text.html', 'numeric-interval': 'question-text.html', 'textarea': 'question-textarea.html', - 'code': 'question-textarea.html', # -- information panels -- 'information': 'question-information.html', 'success': 'question-information.html', @@ -188,19 +186,16 @@ class RootHandler(BaseHandler): Sends test to student or redirects 0 to admin page. Multiple calls to this function will return the same test. ''' - uid = self.current_user logger.debug('"%s" GET /', uid) if uid == '0': self.redirect('/admin') - return - - test = self.testapp.get_test(uid) - name = self.testapp.get_name(uid) - self.render('test.html', - t=test, uid=uid, name=name, md=md_to_html, templ=self._templates) - + else: + test = self.testapp.get_test(uid) + name = self.testapp.get_name(uid) + self.render('test.html', t=test, uid=uid, name=name, md=md_to_html, + templ=self._templates) # --- POST @tornado.web.authenticated @@ -210,8 +205,8 @@ class RootHandler(BaseHandler): renders the grade. self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} - builds dictionary ans={0: 'answer0', 1:, 'answer1', ...} - unanswered questions not included. + builds dictionary ans = {0: 'answer0', 1:, 'answer1', ...} + unanswered questions are not included. ''' starttime = timer() # performance timer @@ -226,17 +221,14 @@ class RootHandler(BaseHandler): ans = {} for i, question in enumerate(test['questions']): qid = str(i) - if 'answered-' + qid in self.request.arguments: + if f'answered-{qid}' in self.request.arguments: ans[i] = self.get_body_arguments(qid) # remove enclosing list in some question types if question['type'] == 'radio': - if not ans[i]: - ans[i] = None - else: - ans[i] = ans[i][0] + ans[i] = ans[i][0] if ans[i] else None elif question['type'] in ('text', 'text-regex', 'textarea', - 'numeric-interval', 'code'): + 'numeric-interval'): ans[i] = ans[i][0] # submit answered questions, correct @@ -253,8 +245,8 @@ class RootHandler(BaseHandler): # pylint: disable=abstract-method class StudentWebservice(BaseHandler): ''' - Receive ajax from students in the test in response from focus, unfocus and - resize events. + Receive ajax from students during the test in response to the events + focus, unfocus and resize. ''' @tornado.web.authenticated @@ -262,8 +254,9 @@ class StudentWebservice(BaseHandler): '''handle ajax post''' uid = self.current_user cmd = self.get_body_argument('cmd', None) - value = json.loads(self.get_body_argument('value', None)) - self.testapp.event_test(uid, cmd, value) + value = self.get_body_argument('value', None) + if cmd is not None and value is not None: + self.testapp.register_event(uid, cmd, json.loads(value)) # ---------------------------------------------------------------------------- @@ -287,8 +280,7 @@ class AdminWebservice(BaseHandler): f'attachment; filename={test_ref}.csv') self.write(data) await self.flush() - - if cmd == 'questionscsv': + elif cmd == 'questionscsv': test_ref, data = self.testapp.get_detailed_grades_csv() self.set_header('Content-Type', 'text/csv') self.set_header('content-Disposition', @@ -344,11 +336,8 @@ class AdminHandler(BaseHandler): await self.testapp.set_password(uid=value, pw='') elif cmd == 'insert_student' and value is not None: student = json.loads(value) - self.testapp.insert_new_student(uid=student['number'], - name=student['name']) - - else: - logger.error('Unknown command: "%s"', cmd) + await self.testapp.insert_new_student(uid=student['number'], + name=student['name']) # ---------------------------------------------------------------------------- @@ -360,7 +349,7 @@ class FileHandler(BaseHandler): Handles static files from questions like images, etc. ''' - _filecache = {} + _filecache: Dict[Tuple[str, str], bytes] = {} @tornado.web.authenticated async def get(self): @@ -390,10 +379,10 @@ class FileHandler(BaseHandler): test = self.testapp.get_test(uid) except KeyError: logger.warning('Could not get test to serve image file') - raise tornado.web.HTTPError(404) # Not Found + raise tornado.web.HTTPError(404) from None # Not Found + # search for the question that contains the image for question in test['questions']: - # search for the question that contains the image if question['ref'] == ref: filepath = path.join(question['path'], 'public', image) @@ -402,13 +391,13 @@ class FileHandler(BaseHandler): data = file.read() except OSError: logger.error('Error reading file "%s"', filepath) - break + return self._filecache[(ref, image)] = data self.write(data) if content_type is not None: self.set_header("Content-Type", content_type) await self.flush() - break + return # --- REVIEW ----------------------------------------------------------------- @@ -425,7 +414,6 @@ class ReviewHandler(BaseHandler): 'text-regex': 'review-question-text.html', 'numeric-interval': 'review-question-text.html', 'textarea': 'review-question-text.html', - 'code': 'review-question-text.html', # -- information panels -- 'information': 'review-question-information.html', 'success': 'review-question-information.html', @@ -460,7 +448,6 @@ class ReviewHandler(BaseHandler): uid = test['student'] name = self.testapp.get_name(uid) - self.render('review.html', t=test, uid=uid, name=name, md=md_to_html, templ=self._templates) diff --git a/perguntations/static/js/admin.js b/perguntations/static/js/admin.js index bc7d2a9..e2dc71e 100644 --- a/perguntations/static/js/admin.js +++ b/perguntations/static/js/admin.js @@ -117,16 +117,13 @@ $(document).ready(function() { d = json.data[i]; var uid = d['uid']; var checked = d['allowed'] ? 'checked' : ''; - var password_defined = d['password_defined'] ? ' ' : ''; + // var password_defined = d['password_defined'] ? ' ' : ''; var hora_inicio = d['start_time'] ? ' ' + d['start_time'].slice(11,16) + '': ''; var unfocus = d['unfocus'] ? ' unfocus' : ''; - var area = ''; - if (d['start_time'] ) { - if (d['area'] > 75) - area = ' ' + Math.round(d['area']) + '%'; - else - area = ' ' + Math.round(d['area']) + '%'; - }; + if (d['area'] > 75) + area = ' ' + Math.round(d['area']) + '%'; + else + area = ' ' + Math.round(d['area']) + '%'; var g = d['grades']; t[i] = []; @@ -134,7 +131,7 @@ $(document).ready(function() { t[i][1] = ' '; t[i][2] = uid; t[i][3] = d['name']; - t[i][4] = password_defined + hora_inicio + area + unfocus; + t[i][4] = d['online'] ? hora_inicio + area + unfocus : ''; var gbar = ''; for (var j=0; j < g.length; j++) diff --git a/perguntations/templates/admin.html b/perguntations/templates/admin.html index cd3498f..1de65d9 100644 --- a/perguntations/templates/admin.html +++ b/perguntations/templates/admin.html @@ -53,8 +53,8 @@ Acções