diff --git a/README.md b/README.md index ac2c478..00fe8cb 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,7 @@ This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in Download and install: ```sh -git clone https://git.xdi.uevora.pt/perguntations.git +git clone https://git.xdi.uevora.pt/mjsb/perguntations.git cd perguntations npm install pip3 install . @@ -225,7 +225,7 @@ Python packages can be upgraded independently of the rest using pip: ```sh pip list --outdated # lists upgradable packages -pip install -U something # upgrade something +pip install -U something # upgrade something ``` To upgrade perguntations and javascript libraries do: diff --git a/demo/demo.yaml b/demo/demo.yaml index 8e4e762..2391207 100644 --- a/demo/demo.yaml +++ b/demo/demo.yaml @@ -22,7 +22,7 @@ title: Teste de demonstração (tutorial) # Duration in minutes. # (0 or undefined means infinite time) -duration: 5 +duration: 20 # Automatic test submission after the given 'duration' timeout # (default: false) @@ -37,11 +37,6 @@ show_points: true # (default: no scaling, just use question points) scale: [0, 5] -# DEPRECATED: old version, to be removed -# scale_max: 20 -# scale_min: 0 -# scale_points: true - # ---------------------------------------------------------------------------- # Base path applied to the questions files and all the scripts # including question generators and correctors. diff --git a/demo/questions/generators/generate-question.py b/demo/questions/generators/generate-question.py index 8503d0f..92c3275 100755 --- a/demo/questions/generators/generate-question.py +++ b/demo/questions/generators/generate-question.py @@ -8,6 +8,7 @@ Arguments are read from stdin. from random import randint import sys +# read two arguments from the field `args` specified in the question yaml file a, b = (int(n) for n in sys.argv[1:]) x = randint(a, b) @@ -18,10 +19,13 @@ print(f"""--- type: text title: Geradores de perguntas text: | - Existe a possibilidade da pergunta ser gerada por um programa externo. O - programa deve escrever no `stdout` uma pergunta em formato `yaml` tal como os - exemplos anteriores. Pode também receber argumentos para parametrizar a - pergunta. Aqui está um exemplo de uma pergunta gerada por um script python: + + As perguntas podem ser estáticas (como as que vimos até aqui), ou serem + geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o + programa deve escrever texto no `stdout` em formato `yaml` tal como os + exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode + também receber argumentos de linha de comando para parametrizar a pergunta. + Aqui está um exemplo de uma pergunta gerada por um script python: ```python #!/usr/bin/env python3 @@ -46,7 +50,7 @@ text: | ``` Este script deve ter permissões para poder ser executado no terminal. - Podemos testar o programa no terminal `./gen-somar.py 1 50` e verificar que + Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar que este script deve ser usado para gerar uma pergunta. @@ -60,7 +64,8 @@ text: | args: [1, 100] ``` - O programa pode receber uma lista de argumentos declarados em `args`. + O programa pode receber uma lista de argumentos de linha de comando + declarados em `args`. --- diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml index 4485fd6..b0e257e 100644 --- a/demo/questions/questions-tutorial.yaml +++ b/demo/questions/questions-tutorial.yaml @@ -24,9 +24,8 @@ duration: 60 # duração da prova em minutos (default: inf) autosubmit: true # submissão automática (default: false) show_points: true # mostra cotação das perguntas (default: true) - scale_points: true # recalcula cotações para [scale_min, scale_max] - scale_max: 20 # limite superior da escala (default: 20) - scale_min: 0 # limite inferior da escala (default: 0) + scale: [0, 20] # limites inferior e superior da escala (default: [0,20]) + scale_points: true # normaliza cotações para a escala definida debug: false # mostra informação de debug no browser # -------------------------------------------------------------------------- @@ -48,9 +47,9 @@ points: 3.5 - ref: pergunta2 - point: 2.0 + points: 2.0 - # a cotação é 1.0 por defeito, se omitida + # a cotação é 1.0 por defeito - ref: pergunta3 # uma string (não dict), é interpretada como referência @@ -153,17 +152,19 @@ entre 0 e 1, sendo atribuída a respectiva cotação, mas só o valor 1 representa uma opção certa. - Por defeito, as opções são apresentadas por ordem aleatória. - Para manter a ordem definida acrescenta-se: + Por defeito, as opções são apresentadas por ordem aleatória, mas é possível + usar a ordem predefinida. Por exemplo, para manter a ordem e indicar que a + resposta correcta é a do meio define-se: ```yaml + correct: [0, 0, 1, 0, 0] shuffle: false ``` - Por defeito, as respostas erradas descontam, tendo uma cotação de - $-1/(n-1)$ do valor da pergunta, onde $n$ é o número de opções apresentadas - ao aluno (a ideia é o valor esperado ser zero quando as respostas são - aleatórias e uniformemente distribuídas). Para não descontar acrescenta-se: + As respostas erradas descontam, tendo uma cotação de $-1/(n-1)$ do valor da + pergunta, onde $n$ é o número de opções apresentadas ao aluno (a ideia é o + valor esperado ser zero quando as respostas são aleatórias e uniformemente + distribuídas). Para não descontar acrescenta-se: ```yaml discount: false @@ -269,7 +270,7 @@ Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`. Em alguns casos pode ser conveniente transformar a resposta antes de a - comparar, por exemplo para remover espaços ou converter para maiúsculas. + comparar, por exemplo para remover espaços ou converter para minúsculas. A opção `transform` permite dar uma sequência de transformações a aplicar à resposta do aluno, por exemplo: @@ -278,10 +279,10 @@ correct: ['azul'] ``` - Neste momento estão disponíveis as seguintes transformações: + Estão disponíveis as seguintes transformações: - * `trim` remove os espaços do início e fim da resposta, os do meio mantêm-se - inalterados. + * `trim` remove os espaços do início e fim da resposta, os espaços do meio + mantêm-se inalterados. * `remove_space` remove todos os espaços (início, meio e fim). * `normalize_space` remove espaços do início e fim (trim), e substitui múltiplos espaços por um único espaço (no meio). @@ -375,10 +376,11 @@ são as mais flexíveis. A resposta é enviada para um programa externo para ser avaliada. - O programa externo é um programa escrito numa linguagem qualquer, desde que - seja executável pelo sistema operativo (pode ser um script ou binário). - Este programa recebe a resposta submetida pelo aluno via `stdin` e devolve - a classificação via `stdout`. + O programa externo é um programa que tem de ser executável pelo pelo + sistema operativo (pode ser um binário ou script desde que o respectivo + interpretador instalado). + Este programa externo recebe a resposta submetida pelo aluno via `stdin` e + devolve a classificação via `stdout`. Exemplo: ```yaml @@ -566,7 +568,7 @@ duas possibilidads: - Imagens inline: não têm título definido e podem ser incluídas no meio de - uma linha de texto usando`![alt text](image.jpg)`. + uma linha de texto usando `![alt text](image.jpg)`. - Imagens centradas com título: `![alt text](image.jpg "Título da imagem")`. O título é colocado por baixo da imagem. Pode ser uma string vazia. diff --git a/package.json b/package.json index 84cd88d..4870e62 100644 --- a/package.json +++ b/package.json @@ -2,13 +2,13 @@ "description": "Javascript libraries required to run the server", "email": "mjsb@uevora.pt", "dependencies": { - "@fortawesome/fontawesome-free": "^5.13.0", - "bootstrap": "^4.4.1", - "codemirror": "^5.53.2", + "@fortawesome/fontawesome-free": "^5.15.1", + "bootstrap": "^4.5.3", + "codemirror": "^5.58.1", "datatables": "^1.10", "jquery": "^3.5.1", - "mathjax": "^3.0.5", + "mathjax": "^3.1.2", "popper.js": "^1.16.1", - "underscore": "^1.10" + "underscore": "^1.11.0" } } diff --git a/perguntations/__init__.py b/perguntations/__init__.py index 15d671f..ffda1c7 100644 --- a/perguntations/__init__.py +++ b/perguntations/__init__.py @@ -32,7 +32,7 @@ proof of submission and for review. ''' APP_NAME = 'perguntations' -APP_VERSION = '2020.05.dev6' +APP_VERSION = '2020.11.dev1' APP_DESCRIPTION = __doc__ __author__ = 'Miguel Barão' diff --git a/perguntations/app.py b/perguntations/app.py index 5a96168..f3f0be4 100644 --- a/perguntations/app.py +++ b/perguntations/app.py @@ -88,24 +88,9 @@ class App(): self.allowed = set() # '0' is hardcoded to allowed elsewhere self.unfocus = set() # set of students that have no browser focus self.area = dict() # {uid: percent_area} + self.pregenerated_tests = [] # list of tests to give to students - logger.info('Loading test configuration "%s".', conf["testfile"]) - try: - testconf = load_yaml(conf['testfile']) - except Exception as exc: - logger.critical('Error loading test configuration YAML.') - raise AppException(exc) - - testconf.update(conf) # command line options override configuration - - # start test factory - try: - self.testfactory = TestFactory(testconf) - except TestFactoryException as exc: - logger.critical(exc) - raise AppException('Failed to create test factory!') - else: - logger.info('No errors found. Test factory ready.') + self._make_test_factory(conf) # connect to database and check registered students dbfile = self.testfactory['database'] @@ -115,28 +100,22 @@ class App(): try: with self.db_session() as sess: num = sess.query(Student).filter(Student.id != '0').count() - except Exception: - raise AppException(f'Database unusable {dbfile}.') - else: - logger.info('Database "%s" has %s students.', dbfile, num) + except Exception as exc: + raise AppException(f'Database unusable {dbfile}.') from exc + logger.info('Database "%s" has %s students.', dbfile, num) + + # pre-generate tests + logger.info('Generating tests for %d students:', num) + self._pregenerate_tests(num) + logger.info('Tests are ready.') # command line option --allow-all if conf['allow_all']: - logger.info('Allowing all students:') - for student in self.get_all_students(): - self.allow_student(student[0]) + self.allow_all_students() else: logger.info('Students not yet allowed to login.') # ------------------------------------------------------------------------ - # FIXME unused??? - # def exit(self): - # if len(self.online) > 1: - # online_students = ', '.join(self.online) - # logger.warning(f'Students still online: {online_students}') - # logger.critical('----------- !!! Server terminated !!! -----------') - - # ------------------------------------------------------------------------ async def login(self, uid, try_pw): '''login authentication''' if uid not in self.allowed and uid != '0': # not allowed @@ -175,18 +154,60 @@ class App(): logger.info('"%s" logged out.', uid) # ------------------------------------------------------------------------ + def _make_test_factory(self, conf): + ''' + Setup a factory for the test + ''' + + # load configuration from yaml file + logger.info('Loading test configuration "%s".', conf["testfile"]) + try: + testconf = load_yaml(conf['testfile']) + except Exception as exc: + msg = 'Error loading test configuration YAML.' + logger.critical(msg) + raise AppException(msg) from exc + + # command line options override configuration + testconf.update(conf) + + # start test factory + logger.info('Making test factory...') + try: + self.testfactory = TestFactory(testconf) + except TestFactoryException as exc: + logger.critical(exc) + raise AppException('Failed to create test factory!') from exc + + logger.info('Test factory ready. No errors found.') + + # ------------------------------------------------------------------------ + def _pregenerate_tests(self, num): + for _ in range(num): + event_loop = asyncio.get_event_loop() + test = event_loop.run_until_complete(self.testfactory.generate()) + self.pregenerated_tests.append(test) + + # ------------------------------------------------------------------------ async def generate_test(self, uid): '''generate a test for a given student''' if uid in self.online: - logger.info('"%s" generating new test.', uid) + try: + test = self.pregenerated_tests.pop() + except IndexError: + logger.info('"%s" generating new test.', uid) + test = await self.testfactory.generate() # student_id) FIXME + else: + logger.info('"%s" using pregenerated test.', uid) + student_id = self.online[uid]['student'] # {number, name} - test = await self.testfactory.generate(student_id) + test.start(student_id) self.online[uid]['test'] = test logger.info('"%s" test is ready.', uid) return self.online[uid]['test'] - # this implies an error in the code. should never be here! - logger.critical('"%s" offline, can\'t generate test', uid) + # this implies an error in the program, code should be unreachable! + logger.critical('"%s" is offline, can\'t generate test', uid) # ------------------------------------------------------------------------ async def correct_test(self, uid, ans): @@ -271,11 +292,11 @@ class App(): '''handles browser events the occur during the test''' if cmd == 'focus': if value: - self.focus_student(uid) + self._focus_student(uid) else: - self.unfocus_student(uid) + self._unfocus_student(uid) elif cmd == 'size': - self.set_screen_area(uid, value) + self._set_screen_area(uid, value) # ------------------------------------------------------------------------ # --- GETTERS @@ -297,11 +318,11 @@ class App(): cols = ['Aluno', 'Início'] + \ [r for question in self.testfactory['questions'] - for r in question['ref']] + for r in question['ref']] tests = {} - for q in grades: - student, qref, qgrade = q[:2], q[2], q[3] + for question in grades: + student, qref, qgrade = question[:2], *question[2:] tests.setdefault(student, {})[qref] = qgrade rows = [{'Aluno': test[0], 'Início': test[1], **q} @@ -351,13 +372,6 @@ class App(): .filter_by(id=test_id)\ .scalar() - def get_all_students(self): - '''get all students from database''' - with self.db_session() as sess: - return sess.query(Student.id, Student.name, Student.password)\ - .filter(Student.id != '0')\ - .order_by(Student.id) - def get_student_grades_from_test(self, uid, testid): '''get grades of student for a given testid''' with self.db_session() as sess: @@ -380,7 +394,15 @@ class App(): 'area': self.area.get(uid, None), 'grades': self.get_student_grades_from_test( uid, self.testfactory['ref']) - } for uid, name, pw in self.get_all_students()] + } for uid, name, pw in self._get_all_students()] + + # --- private methods ---------------------------------------------------- + def _get_all_students(self): + '''get all students from database''' + with self.db_session() as sess: + return sess.query(Student.id, Student.name, Student.password)\ + .filter(Student.id != '0')\ + .order_by(Student.id) # def get_allowed_students(self): # # set of 'uid' allowed to login @@ -409,30 +431,31 @@ class App(): def allow_all_students(self): '''allow all students to login''' - logger.info('Allowing all students...') - self.allowed.update(s[0] for s in self.get_all_students()) + all_students = self._get_all_students() + self.allowed.update(s[0] for s in all_students) + logger.info('Allowed all students.') def deny_all_students(self): '''deny all students to login''' logger.info('Denying all students...') self.allowed.clear() - def focus_student(self, uid): + def _focus_student(self, uid): '''set student in focus state''' self.unfocus.discard(uid) logger.info('"%s" focus', uid) - def unfocus_student(self, 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): + 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', + logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', uid, area, win_x, win_y, scr_x, scr_y) async def update_student_password(self, uid, password=''): diff --git a/perguntations/initdb.py b/perguntations/initdb.py index 5cb9ed5..767bc0e 100644 --- a/perguntations/initdb.py +++ b/perguntations/initdb.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 ''' -Commandline utilizty to initialize and update student database +Commandline utility to initialize and update student database ''' # base diff --git a/perguntations/main.py b/perguntations/main.py index 52a44f1..a0ef770 100644 --- a/perguntations/main.py +++ b/perguntations/main.py @@ -15,10 +15,10 @@ import sys # from typing import Any, Dict # this project -from .app import App, AppException -from .serve import run_webserver -from .tools import load_yaml -from . import APP_NAME, APP_VERSION +from perguntations.app import App, AppException +from perguntations.serve import run_webserver +from perguntations.tools import load_yaml +from perguntations import APP_NAME, APP_VERSION # ---------------------------------------------------------------------------- @@ -123,9 +123,8 @@ def main(): 'review': args.review, } - # testapp = App(config) try: - testapp = App(config) + app = App(config) except AppException: logging.critical('Failed to start application.') sys.exit(-1) @@ -145,8 +144,7 @@ def main(): sys.exit(-1) # --- run webserver ---------------------------------------------------- - run_webserver(app=testapp, ssl_opt=ssl_opt, port=args.port, - debug=args.debug) + run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug) # ---------------------------------------------------------------------------- diff --git a/perguntations/parser_markdown.py b/perguntations/parser_markdown.py index 93c80e0..e04dbbd 100644 --- a/perguntations/parser_markdown.py +++ b/perguntations/parser_markdown.py @@ -1,3 +1,4 @@ + ''' Parse markdown and generate HTML Includes support for LaTeX formulas @@ -25,12 +26,19 @@ logger = logging.getLogger(__name__) # Block math: $$x$$ or \begin{equation}x\end{equation} # ------------------------------------------------------------------------- class MathBlockGrammar(mistune.BlockGrammar): + ''' + match block math $$x$$ and math environments begin{} end{} + ''' + # pylint: disable=too-few-public-methods block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL) class MathBlockLexer(mistune.BlockLexer): + ''' + parser for block math and latex environment + ''' default_rules = ['block_math', 'latex_environment'] \ + mistune.BlockLexer.default_rules @@ -56,12 +64,19 @@ class MathBlockLexer(mistune.BlockLexer): class MathInlineGrammar(mistune.InlineGrammar): + ''' + match inline math $x$, block math $$x$$ and text + ''' + # pylint: disable=too-few-public-methods math = re.compile(r"^\$(.+?)\$", re.DOTALL) block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL) text = re.compile(r'^[\s\S]+?(?=[\\' \ + header + '' + body + '' @@ -141,14 +168,17 @@ class HighlightRenderer(mistune.Renderer): # Pass math through unaltered - mathjax does the rendering in the browser def block_math(self, text): '''bypass block math''' + # pylint: disable=no-self-use return fr'$$ {text} $$' def latex_environment(self, name, text): '''bypass latex environment''' + # pylint: disable=no-self-use return fr'\begin{{{name}}} {text} \end{{{name}}}' def inline_math(self, text): '''bypass inline math''' + # pylint: disable=no-self-use return fr'$$$ {text} $$$' diff --git a/perguntations/questions.py b/perguntations/questions.py index 4b8622b..1d429fc 100644 --- a/perguntations/questions.py +++ b/perguntations/questions.py @@ -13,7 +13,7 @@ from typing import Any, Dict, NewType import uuid # this project -from .tools import run_script, run_script_async +from perguntations.tools import run_script, run_script_async # setup logger for this module logger = logging.getLogger(__name__) @@ -110,10 +110,10 @@ class QuestionRadio(Question): # make sure is a list of floats try: self['correct'] = [float(x) for x in self['correct']] - except (ValueError, TypeError): + except (ValueError, TypeError) as exc: msg = (f'Correct list must contain numbers [0.0, 1.0] or ' f'booleans in "{self["ref"]}"') - raise QuestionException(msg) + raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 @@ -217,10 +217,10 @@ class QuestionCheckbox(Question): # make sure is a list of floats try: self['correct'] = [float(x) for x in self['correct']] - except (ValueError, TypeError): + except (ValueError, TypeError) as exc: msg = (f'Correct list must contain numbers or ' f'booleans in "{self["ref"]}"') - raise QuestionException(msg) + raise QuestionException(msg) from exc # check grade boundaries if self['discount'] and not all(0.0 <= x <= 1.0 @@ -379,9 +379,9 @@ class QuestionTextRegex(Question): # converts patterns to compiled versions try: self['correct'] = [re.compile(a) for a in self['correct']] - except Exception: + except Exception as exc: msg = f'Failed to compile regex in "{self["ref"]}"' - raise QuestionException(msg) + raise QuestionException(msg) from exc # ------------------------------------------------------------------------ def correct(self) -> None: @@ -430,10 +430,10 @@ class QuestionNumericInterval(Question): try: self['correct'] = [float(n) for n in self['correct']] - except Exception: + except Exception as exc: msg = (f'Numeric interval must be a list with two numbers, in ' f'{self["ref"]}') - raise QuestionException(msg) + raise QuestionException(msg) from exc # invalid else: diff --git a/perguntations/serve.py b/perguntations/serve.py index 50f27f1..bafce34 100644 --- a/perguntations/serve.py +++ b/perguntations/serve.py @@ -1,8 +1,8 @@ #!/usr/bin/env python3 ''' -Handles the web and html part of the application interface. -The tornadoweb framework is used. +Handles the web, http & html part of the application interface. +Uses the tornadoweb framework. ''' @@ -40,8 +40,8 @@ class WebApplication(tornado.web.Application): (r'/review', ReviewHandler), (r'/admin', AdminHandler), (r'/file', FileHandler), - # (r'/root', MainHandler), # FIXME - # (r'/ws', AdminSocketHandler), + # (r'/root', MainHandler), + # (r'/ws', AdminSocketHandler), (r'/adminwebservice', AdminWebservice), (r'/studentwebservice', StudentWebservice), (r'/', RootHandler), @@ -66,7 +66,7 @@ def admin_only(func): Decorator used to restrict access to the administrator. Example: - @admin_only() + @admin_only def get(self): ... ''' @functools.wraps(func) @@ -78,6 +78,7 @@ def admin_only(func): # ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class BaseHandler(tornado.web.RequestHandler): ''' Handlers should inherit this one instead of tornado.web.RequestHandler. @@ -87,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler): @property def testapp(self): - '''simplifies access to the application''' + '''simplifies access to the application a little bit''' return self.application.testapp def get_current_user(self): @@ -158,6 +159,8 @@ class BaseHandler(tornado.web.RequestHandler): # AdminSocketHandler.update_cache(chat) # store msgs # AdminSocketHandler.send_updates(chat) # send to clients +# ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class StudentWebservice(BaseHandler): ''' Receive ajax from students in the test in response from focus, unfocus and @@ -174,6 +177,7 @@ class StudentWebservice(BaseHandler): # ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class AdminWebservice(BaseHandler): ''' Receive ajax requests from admin @@ -202,6 +206,7 @@ class AdminWebservice(BaseHandler): # ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class AdminHandler(BaseHandler): '''Handle /admin''' @@ -260,6 +265,7 @@ class AdminHandler(BaseHandler): # ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class LoginHandler(BaseHandler): '''Handle /login''' @@ -282,6 +288,7 @@ class LoginHandler(BaseHandler): # ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class LogoutHandler(BaseHandler): '''Handle /logout''' @@ -296,6 +303,7 @@ class LogoutHandler(BaseHandler): # ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class RootHandler(BaseHandler): ''' Handles / to redirect students and admin to /test and /admin, resp. @@ -315,6 +323,7 @@ class RootHandler(BaseHandler): # ---------------------------------------------------------------------------- # Serves files from the /public subdir of the topics. # ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class FileHandler(BaseHandler): ''' Handles static files from questions like images, etc. @@ -366,6 +375,7 @@ class FileHandler(BaseHandler): # ---------------------------------------------------------------------------- # Test shown to students # ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class TestHandler(BaseHandler): ''' Generates test to student. @@ -373,6 +383,7 @@ class TestHandler(BaseHandler): ''' _templates = { + # -- question templates -- 'radio': 'question-radio.html', 'checkbox': 'question-checkbox.html', 'text': 'question-text.html', @@ -396,6 +407,7 @@ class TestHandler(BaseHandler): test = self.testapp.get_student_test(uid) # reloading returns same test if test is None: test = await self.testapp.generate_test(uid) + self.render('test.html', t=test, md=md_to_html, templ=self._templates) # --- POST @@ -438,20 +450,8 @@ class TestHandler(BaseHandler): self.render('grade.html', t=test, allgrades=allgrades) -# ---------------------------------------------------------------------------- -# FIXME should be a post in the test with command giveup instead of correct... -# class GiveupHandler(BaseHandler): -# @tornado.web.authenticated -# def get(self): -# uid = self.current_user -# t = self.testapp.giveup_test(uid) -# self.testapp.logout(uid) - -# # --- Show result to student -# self.render('grade.html', t=t, allgrades=self.testapp.get_student_grades_from_all_tests(uid)) - - # --- REVIEW ----------------------------------------------------------------- +# pylint: disable=abstract-method class ReviewHandler(BaseHandler): ''' Show test for review @@ -488,18 +488,20 @@ class ReviewHandler(BaseHandler): with open(path.expanduser(fname)) as jsonfile: test = json.load(jsonfile) except OSError: - logging.error('Cannot open "%s" for review.', fname) - raise tornado.web.HTTPError(404) # Not Found + msg = f'Cannot open "{fname}" for review.' + logging.error(msg) + raise tornado.web.HTTPError(status_code=404, reason=msg) from None except json.JSONDecodeError as exc: - logging.error('JSON error in "%s": %s', fname, exc) - raise tornado.web.HTTPError(404) # Not Found + msg = f'JSON error in "{fname}": {exc}' + logging.error(msg) + raise tornado.web.HTTPError(status_code=404, reason=msg) self.render('review.html', t=test, md=md_to_html, - templ=self._templates) + templ=self._templates) # ---------------------------------------------------------------------------- -def signal_handler(sig, frame): +def signal_handler(*_): ''' Catches Ctrl-C and stops webserver ''' diff --git a/perguntations/static/js/admin.js b/perguntations/static/js/admin.js index 5cd23a8..bc7d2a9 100644 --- a/perguntations/static/js/admin.js +++ b/perguntations/static/js/admin.js @@ -119,7 +119,7 @@ $(document).ready(function() { var checked = d['allowed'] ? 'checked' : ''; var password_defined = d['password_defined'] ? ' ' : ''; var hora_inicio = d['start_time'] ? ' ' + d['start_time'].slice(11,16) + '': ''; - var unfocus = d['unfocus']? ' unfocus' : ''; + var unfocus = d['unfocus'] ? ' unfocus' : ''; var area = ''; if (d['start_time'] ) { if (d['area'] > 75) diff --git a/perguntations/templates/test.html b/perguntations/templates/test.html index 69830aa..7b6c24d 100644 --- a/perguntations/templates/test.html +++ b/perguntations/templates/test.html @@ -44,7 +44,7 @@ -
+
diff --git a/perguntations/test.py b/perguntations/test.py index 3f53e31..8c1c1da 100644 --- a/perguntations/test.py +++ b/perguntations/test.py @@ -111,9 +111,9 @@ class TestFactory(dict): # check if all the questions can be correctly generated try: self.question_factory[question['ref']].generate() - except Exception: + except Exception as exc: msg = f'Failed to generate "{question["ref"]}"' - raise TestFactoryException(msg) + raise TestFactoryException(msg) from exc else: logger.info('%4d. "%s" Ok.', counter, question["ref"]) counter += 1 @@ -152,9 +152,9 @@ class TestFactory(dict): try: with open(testfile, 'w') as file: file.write('You can safely remove this file.') - except OSError: + except OSError as exc: msg = f'Cannot write answers to directory "{self["answers_dir"]}"' - raise TestFactoryException(msg) + raise TestFactoryException(msg) from exc def check_questions_directory(self): '''Check if questions directory is missing or not accessible.''' @@ -223,16 +223,16 @@ class TestFactory(dict): self.check_grade_scaling() # ------------------------------------------------------------------------ - async def generate(self, student): + async def generate(self): #, student): ''' Given a dictionary with a student dict {'name':'john', 'number': 123} returns instance of Test() for that particular student ''' # make list of questions - test = [] - qnum = 1 # track question number - nerr = 0 # count errors generating questions + questions = [] + qnum = 1 # track question number + nerr = 0 # count errors during questions generation for qlist in self['questions']: # choose one question variant @@ -255,28 +255,28 @@ class TestFactory(dict): question['number'] = qnum # counter for non informative panels qnum += 1 - test.append(question) + questions.append(question) # setup scale - total_points = sum(q['points'] for q in test) + total_points = sum(q['points'] for q in questions) if total_points > 0: # normalize question points to scale if self['scale'] is not None: scale_min, scale_max = self['scale'] - for question in test: + for question in questions: question['points'] *= (scale_max - scale_min) / total_points else: self['scale'] = [0, total_points] else: logger.warning('Total points is **ZERO**.') if self['scale'] is None: - self['scale'] = [0, 20] + self['scale'] = [0, 20] # default if nerr > 0: logger.error('%s errors found!', nerr) - # these will be copied to the test instance + # copy these from the test configuratoin to each test instance inherit = {'ref', 'title', 'database', 'answers_dir', 'questions_dir', 'files', 'duration', 'autosubmit', @@ -284,9 +284,7 @@ class TestFactory(dict): 'show_ref', 'debug', } # NOT INCLUDED: testfile, allow_all, review - return Test({ - **{'student': student, 'questions': test}, - **{k:self[k] for k in inherit}}) + return Test({'questions': questions, **{k:self[k] for k in inherit}}) # ------------------------------------------------------------------------ def __repr__(self): @@ -301,8 +299,15 @@ class Test(dict): ''' # ------------------------------------------------------------------------ - def __init__(self, d): - super().__init__(d) + # def __init__(self, d): + # super().__init__(d) + + # ------------------------------------------------------------------------ + def start(self, student): + ''' + Write student id in the test and register start time + ''' + self['student'] = student self['start_time'] = datetime.now() self['finish_time'] = None self['state'] = 'ACTIVE' @@ -346,5 +351,12 @@ class Test(dict): self['finish_time'] = datetime.now() self['state'] = 'QUIT' self['grade'] = 0.0 - logger.info('Student %s: gave up.', self["student"]["number"]) + # logger.info('Student %s: gave up.', self["student"]["number"]) return self['grade'] + + # ------------------------------------------------------------------------ + def __str__(self): + return ('Test:\n' + f' student: {self.get("student", "--")}\n' + f' start_time: {self.get("start_time", "--")}\n' + f' questions: {", ".join(q["ref"] for q in self["questions"])}\n') -- libgit2 0.21.2