Commit 1200ef9cd00ee5f329dcf18f0d140ad7a5aff94d

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

fix README.md instructions and demo.

code refactoring.
pre-generate tests
README.md
... ... @@ -39,7 +39,7 @@ This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in
39 39 Download and install:
40 40  
41 41 ```sh
42   -git clone https://git.xdi.uevora.pt/perguntations.git
  42 +git clone https://git.xdi.uevora.pt/mjsb/perguntations.git
43 43 cd perguntations
44 44 npm install
45 45 pip3 install .
... ... @@ -225,7 +225,7 @@ Python packages can be upgraded independently of the rest using pip:
225 225  
226 226 ```sh
227 227 pip list --outdated # lists upgradable packages
228   -pip install -U something # upgrade something
  228 +pip install -U something # upgrade something
229 229 ```
230 230  
231 231 To upgrade perguntations and javascript libraries do:
... ...
demo/demo.yaml
... ... @@ -22,7 +22,7 @@ title: Teste de demonstração (tutorial)
22 22  
23 23 # Duration in minutes.
24 24 # (0 or undefined means infinite time)
25   -duration: 5
  25 +duration: 20
26 26  
27 27 # Automatic test submission after the given 'duration' timeout
28 28 # (default: false)
... ... @@ -37,11 +37,6 @@ show_points: true
37 37 # (default: no scaling, just use question points)
38 38 scale: [0, 5]
39 39  
40   -# DEPRECATED: old version, to be removed
41   -# scale_max: 20
42   -# scale_min: 0
43   -# scale_points: true
44   -
45 40 # ----------------------------------------------------------------------------
46 41 # Base path applied to the questions files and all the scripts
47 42 # including question generators and correctors.
... ...
demo/questions/generators/generate-question.py
... ... @@ -8,6 +8,7 @@ Arguments are read from stdin.
8 8 from random import randint
9 9 import sys
10 10  
  11 +# read two arguments from the field `args` specified in the question yaml file
11 12 a, b = (int(n) for n in sys.argv[1:])
12 13  
13 14 x = randint(a, b)
... ... @@ -18,10 +19,13 @@ print(f"""---
18 19 type: text
19 20 title: Geradores de perguntas
20 21 text: |
21   - Existe a possibilidade da pergunta ser gerada por um programa externo. O
22   - programa deve escrever no `stdout` uma pergunta em formato `yaml` tal como os
23   - exemplos anteriores. Pode também receber argumentos para parametrizar a
24   - pergunta. Aqui está um exemplo de uma pergunta gerada por um script python:
  22 +
  23 + As perguntas podem ser estáticas (como as que vimos até aqui), ou serem
  24 + geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o
  25 + programa deve escrever texto no `stdout` em formato `yaml` tal como os
  26 + exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode
  27 + também receber argumentos de linha de comando para parametrizar a pergunta.
  28 + Aqui está um exemplo de uma pergunta gerada por um script python:
25 29  
26 30 ```python
27 31 #!/usr/bin/env python3
... ... @@ -46,7 +50,7 @@ text: |
46 50 ```
47 51  
48 52 Este script deve ter permissões para poder ser executado no terminal.
49   - Podemos testar o programa no terminal `./gen-somar.py 1 50` e verificar que
  53 + Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que
50 54 o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar
51 55 que este script deve ser usado para gerar uma pergunta.
52 56  
... ... @@ -60,7 +64,8 @@ text: |
60 64 args: [1, 100]
61 65 ```
62 66  
63   - O programa pode receber uma lista de argumentos declarados em `args`.
  67 + O programa pode receber uma lista de argumentos de linha de comando
  68 + declarados em `args`.
64 69  
65 70 ---
66 71  
... ...
demo/questions/questions-tutorial.yaml
... ... @@ -24,9 +24,8 @@
24 24 duration: 60 # duração da prova em minutos (default: inf)
25 25 autosubmit: true # submissão automática (default: false)
26 26 show_points: true # mostra cotação das perguntas (default: true)
27   - scale_points: true # recalcula cotações para [scale_min, scale_max]
28   - scale_max: 20 # limite superior da escala (default: 20)
29   - scale_min: 0 # limite inferior da escala (default: 0)
  27 + scale: [0, 20] # limites inferior e superior da escala (default: [0,20])
  28 + scale_points: true # normaliza cotações para a escala definida
30 29 debug: false # mostra informação de debug no browser
31 30  
32 31 # --------------------------------------------------------------------------
... ... @@ -48,9 +47,9 @@
48 47 points: 3.5
49 48  
50 49 - ref: pergunta2
51   - point: 2.0
  50 + points: 2.0
52 51  
53   - # a cotação é 1.0 por defeito, se omitida
  52 + # a cotação é 1.0 por defeito
54 53 - ref: pergunta3
55 54  
56 55 # uma string (não dict), é interpretada como referência
... ... @@ -153,17 +152,19 @@
153 152 entre 0 e 1, sendo atribuída a respectiva cotação, mas só o valor 1
154 153 representa uma opção certa.
155 154  
156   - Por defeito, as opções são apresentadas por ordem aleatória.
157   - Para manter a ordem definida acrescenta-se:
  155 + Por defeito, as opções são apresentadas por ordem aleatória, mas é possível
  156 + usar a ordem predefinida. Por exemplo, para manter a ordem e indicar que a
  157 + resposta correcta é a do meio define-se:
158 158  
159 159 ```yaml
  160 + correct: [0, 0, 1, 0, 0]
160 161 shuffle: false
161 162 ```
162 163  
163   - Por defeito, as respostas erradas descontam, tendo uma cotação de
164   - $-1/(n-1)$ do valor da pergunta, onde $n$ é o número de opções apresentadas
165   - ao aluno (a ideia é o valor esperado ser zero quando as respostas são
166   - aleatórias e uniformemente distribuídas). Para não descontar acrescenta-se:
  164 + As respostas erradas descontam, tendo uma cotação de $-1/(n-1)$ do valor da
  165 + pergunta, onde $n$ é o número de opções apresentadas ao aluno (a ideia é o
  166 + valor esperado ser zero quando as respostas são aleatórias e uniformemente
  167 + distribuídas). Para não descontar acrescenta-se:
167 168  
168 169 ```yaml
169 170 discount: false
... ... @@ -269,7 +270,7 @@
269 270 Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`.
270 271  
271 272 Em alguns casos pode ser conveniente transformar a resposta antes de a
272   - comparar, por exemplo para remover espaços ou converter para maiúsculas.
  273 + comparar, por exemplo para remover espaços ou converter para minúsculas.
273 274 A opção `transform` permite dar uma sequência de transformações a aplicar à
274 275 resposta do aluno, por exemplo:
275 276  
... ... @@ -278,10 +279,10 @@
278 279 correct: ['azul']
279 280 ```
280 281  
281   - Neste momento estão disponíveis as seguintes transformações:
  282 + Estão disponíveis as seguintes transformações:
282 283  
283   - * `trim` remove os espaços do início e fim da resposta, os do meio mantêm-se
284   - inalterados.
  284 + * `trim` remove os espaços do início e fim da resposta, os espaços do meio
  285 + mantêm-se inalterados.
285 286 * `remove_space` remove todos os espaços (início, meio e fim).
286 287 * `normalize_space` remove espaços do início e fim (trim), e substitui
287 288 múltiplos espaços por um único espaço (no meio).
... ... @@ -375,10 +376,11 @@
375 376 são as mais flexíveis.
376 377  
377 378 A resposta é enviada para um programa externo para ser avaliada.
378   - O programa externo é um programa escrito numa linguagem qualquer, desde que
379   - seja executável pelo sistema operativo (pode ser um script ou binário).
380   - Este programa recebe a resposta submetida pelo aluno via `stdin` e devolve
381   - a classificação via `stdout`.
  379 + O programa externo é um programa que tem de ser executável pelo pelo
  380 + sistema operativo (pode ser um binário ou script desde que o respectivo
  381 + interpretador instalado).
  382 + Este programa externo recebe a resposta submetida pelo aluno via `stdin` e
  383 + devolve a classificação via `stdout`.
382 384 Exemplo:
383 385  
384 386 ```yaml
... ... @@ -566,7 +568,7 @@
566 568 duas possibilidads:
567 569  
568 570 - Imagens inline: não têm título definido e podem ser incluídas no meio de
569   - uma linha de texto usando`![alt text](image.jpg)`.
  571 + uma linha de texto usando `![alt text](image.jpg)`.
570 572 - Imagens centradas com título: `![alt text](image.jpg "Título da imagem")`.
571 573 O título é colocado por baixo da imagem. Pode ser uma string vazia.
572 574  
... ...
package.json
... ... @@ -2,13 +2,13 @@
2 2 "description": "Javascript libraries required to run the server",
3 3 "email": "mjsb@uevora.pt",
4 4 "dependencies": {
5   - "@fortawesome/fontawesome-free": "^5.13.0",
6   - "bootstrap": "^4.4.1",
7   - "codemirror": "^5.53.2",
  5 + "@fortawesome/fontawesome-free": "^5.15.1",
  6 + "bootstrap": "^4.5.3",
  7 + "codemirror": "^5.58.1",
8 8 "datatables": "^1.10",
9 9 "jquery": "^3.5.1",
10   - "mathjax": "^3.0.5",
  10 + "mathjax": "^3.1.2",
11 11 "popper.js": "^1.16.1",
12   - "underscore": "^1.10"
  12 + "underscore": "^1.11.0"
13 13 }
14 14 }
... ...
perguntations/__init__.py
... ... @@ -32,7 +32,7 @@ proof of submission and for review.
32 32 '''
33 33  
34 34 APP_NAME = 'perguntations'
35   -APP_VERSION = '2020.05.dev6'
  35 +APP_VERSION = '2020.11.dev1'
36 36 APP_DESCRIPTION = __doc__
37 37  
38 38 __author__ = 'Miguel Barão'
... ...
perguntations/app.py
... ... @@ -88,24 +88,9 @@ class App():
88 88 self.allowed = set() # '0' is hardcoded to allowed elsewhere
89 89 self.unfocus = set() # set of students that have no browser focus
90 90 self.area = dict() # {uid: percent_area}
  91 + self.pregenerated_tests = [] # list of tests to give to students
91 92  
92   - logger.info('Loading test configuration "%s".', conf["testfile"])
93   - try:
94   - testconf = load_yaml(conf['testfile'])
95   - except Exception as exc:
96   - logger.critical('Error loading test configuration YAML.')
97   - raise AppException(exc)
98   -
99   - testconf.update(conf) # command line options override configuration
100   -
101   - # start test factory
102   - try:
103   - self.testfactory = TestFactory(testconf)
104   - except TestFactoryException as exc:
105   - logger.critical(exc)
106   - raise AppException('Failed to create test factory!')
107   - else:
108   - logger.info('No errors found. Test factory ready.')
  93 + self._make_test_factory(conf)
109 94  
110 95 # connect to database and check registered students
111 96 dbfile = self.testfactory['database']
... ... @@ -115,28 +100,22 @@ class App():
115 100 try:
116 101 with self.db_session() as sess:
117 102 num = sess.query(Student).filter(Student.id != '0').count()
118   - except Exception:
119   - raise AppException(f'Database unusable {dbfile}.')
120   - else:
121   - logger.info('Database "%s" has %s students.', dbfile, num)
  103 + except Exception as exc:
  104 + raise AppException(f'Database unusable {dbfile}.') from exc
  105 + logger.info('Database "%s" has %s students.', dbfile, num)
  106 +
  107 + # pre-generate tests
  108 + logger.info('Generating tests for %d students:', num)
  109 + self._pregenerate_tests(num)
  110 + logger.info('Tests are ready.')
122 111  
123 112 # command line option --allow-all
124 113 if conf['allow_all']:
125   - logger.info('Allowing all students:')
126   - for student in self.get_all_students():
127   - self.allow_student(student[0])
  114 + self.allow_all_students()
128 115 else:
129 116 logger.info('Students not yet allowed to login.')
130 117  
131 118 # ------------------------------------------------------------------------
132   - # FIXME unused???
133   - # def exit(self):
134   - # if len(self.online) > 1:
135   - # online_students = ', '.join(self.online)
136   - # logger.warning(f'Students still online: {online_students}')
137   - # logger.critical('----------- !!! Server terminated !!! -----------')
138   -
139   - # ------------------------------------------------------------------------
140 119 async def login(self, uid, try_pw):
141 120 '''login authentication'''
142 121 if uid not in self.allowed and uid != '0': # not allowed
... ... @@ -175,18 +154,60 @@ class App():
175 154 logger.info('"%s" logged out.', uid)
176 155  
177 156 # ------------------------------------------------------------------------
  157 + def _make_test_factory(self, conf):
  158 + '''
  159 + Setup a factory for the test
  160 + '''
  161 +
  162 + # load configuration from yaml file
  163 + logger.info('Loading test configuration "%s".', conf["testfile"])
  164 + try:
  165 + testconf = load_yaml(conf['testfile'])
  166 + except Exception as exc:
  167 + msg = 'Error loading test configuration YAML.'
  168 + logger.critical(msg)
  169 + raise AppException(msg) from exc
  170 +
  171 + # command line options override configuration
  172 + testconf.update(conf)
  173 +
  174 + # start test factory
  175 + logger.info('Making test factory...')
  176 + try:
  177 + self.testfactory = TestFactory(testconf)
  178 + except TestFactoryException as exc:
  179 + logger.critical(exc)
  180 + raise AppException('Failed to create test factory!') from exc
  181 +
  182 + logger.info('Test factory ready. No errors found.')
  183 +
  184 + # ------------------------------------------------------------------------
  185 + def _pregenerate_tests(self, num):
  186 + for _ in range(num):
  187 + event_loop = asyncio.get_event_loop()
  188 + test = event_loop.run_until_complete(self.testfactory.generate())
  189 + self.pregenerated_tests.append(test)
  190 +
  191 + # ------------------------------------------------------------------------
178 192 async def generate_test(self, uid):
179 193 '''generate a test for a given student'''
180 194 if uid in self.online:
181   - logger.info('"%s" generating new test.', uid)
  195 + try:
  196 + test = self.pregenerated_tests.pop()
  197 + except IndexError:
  198 + logger.info('"%s" generating new test.', uid)
  199 + test = await self.testfactory.generate() # student_id) FIXME
  200 + else:
  201 + logger.info('"%s" using pregenerated test.', uid)
  202 +
182 203 student_id = self.online[uid]['student'] # {number, name}
183   - test = await self.testfactory.generate(student_id)
  204 + test.start(student_id)
184 205 self.online[uid]['test'] = test
185 206 logger.info('"%s" test is ready.', uid)
186 207 return self.online[uid]['test']
187 208  
188   - # this implies an error in the code. should never be here!
189   - logger.critical('"%s" offline, can\'t generate test', uid)
  209 + # this implies an error in the program, code should be unreachable!
  210 + logger.critical('"%s" is offline, can\'t generate test', uid)
190 211  
191 212 # ------------------------------------------------------------------------
192 213 async def correct_test(self, uid, ans):
... ... @@ -271,11 +292,11 @@ class App():
271 292 '''handles browser events the occur during the test'''
272 293 if cmd == 'focus':
273 294 if value:
274   - self.focus_student(uid)
  295 + self._focus_student(uid)
275 296 else:
276   - self.unfocus_student(uid)
  297 + self._unfocus_student(uid)
277 298 elif cmd == 'size':
278   - self.set_screen_area(uid, value)
  299 + self._set_screen_area(uid, value)
279 300  
280 301 # ------------------------------------------------------------------------
281 302 # --- GETTERS
... ... @@ -297,11 +318,11 @@ class App():
297 318  
298 319 cols = ['Aluno', 'Início'] + \
299 320 [r for question in self.testfactory['questions']
300   - for r in question['ref']]
  321 + for r in question['ref']]
301 322  
302 323 tests = {}
303   - for q in grades:
304   - student, qref, qgrade = q[:2], q[2], q[3]
  324 + for question in grades:
  325 + student, qref, qgrade = question[:2], *question[2:]
305 326 tests.setdefault(student, {})[qref] = qgrade
306 327  
307 328 rows = [{'Aluno': test[0], 'Início': test[1], **q}
... ... @@ -351,13 +372,6 @@ class App():
351 372 .filter_by(id=test_id)\
352 373 .scalar()
353 374  
354   - def get_all_students(self):
355   - '''get all students from database'''
356   - with self.db_session() as sess:
357   - return sess.query(Student.id, Student.name, Student.password)\
358   - .filter(Student.id != '0')\
359   - .order_by(Student.id)
360   -
361 375 def get_student_grades_from_test(self, uid, testid):
362 376 '''get grades of student for a given testid'''
363 377 with self.db_session() as sess:
... ... @@ -380,7 +394,15 @@ class App():
380 394 'area': self.area.get(uid, None),
381 395 'grades': self.get_student_grades_from_test(
382 396 uid, self.testfactory['ref'])
383   - } for uid, name, pw in self.get_all_students()]
  397 + } for uid, name, pw in self._get_all_students()]
  398 +
  399 + # --- private methods ----------------------------------------------------
  400 + def _get_all_students(self):
  401 + '''get all students from database'''
  402 + with self.db_session() as sess:
  403 + return sess.query(Student.id, Student.name, Student.password)\
  404 + .filter(Student.id != '0')\
  405 + .order_by(Student.id)
384 406  
385 407 # def get_allowed_students(self):
386 408 # # set of 'uid' allowed to login
... ... @@ -409,30 +431,31 @@ class App():
409 431  
410 432 def allow_all_students(self):
411 433 '''allow all students to login'''
412   - logger.info('Allowing all students...')
413   - self.allowed.update(s[0] for s in self.get_all_students())
  434 + all_students = self._get_all_students()
  435 + self.allowed.update(s[0] for s in all_students)
  436 + logger.info('Allowed all students.')
414 437  
415 438 def deny_all_students(self):
416 439 '''deny all students to login'''
417 440 logger.info('Denying all students...')
418 441 self.allowed.clear()
419 442  
420   - def focus_student(self, uid):
  443 + def _focus_student(self, uid):
421 444 '''set student in focus state'''
422 445 self.unfocus.discard(uid)
423 446 logger.info('"%s" focus', uid)
424 447  
425   - def unfocus_student(self, uid):
  448 + def _unfocus_student(self, uid):
426 449 '''set student in unfocus state'''
427 450 self.unfocus.add(uid)
428 451 logger.info('"%s" unfocus', uid)
429 452  
430   - def set_screen_area(self, uid, sizes):
  453 + def _set_screen_area(self, uid, sizes):
431 454 '''set current browser area as detected in resize event'''
432 455 scr_y, scr_x, win_y, win_x = sizes
433 456 area = win_x * win_y / (scr_x * scr_y) * 100
434 457 self.area[uid] = area
435   - logger.info('"%s": area=%g%%, window=%dx%d, screen=%dx%d',
  458 + logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d',
436 459 uid, area, win_x, win_y, scr_x, scr_y)
437 460  
438 461 async def update_student_password(self, uid, password=''):
... ...
perguntations/initdb.py
1 1 #!/usr/bin/env python3
2 2  
3 3 '''
4   -Commandline utilizty to initialize and update student database
  4 +Commandline utility to initialize and update student database
5 5 '''
6 6  
7 7 # base
... ...
perguntations/main.py
... ... @@ -15,10 +15,10 @@ import sys
15 15 # from typing import Any, Dict
16 16  
17 17 # this project
18   -from .app import App, AppException
19   -from .serve import run_webserver
20   -from .tools import load_yaml
21   -from . import APP_NAME, APP_VERSION
  18 +from perguntations.app import App, AppException
  19 +from perguntations.serve import run_webserver
  20 +from perguntations.tools import load_yaml
  21 +from perguntations import APP_NAME, APP_VERSION
22 22  
23 23  
24 24 # ----------------------------------------------------------------------------
... ... @@ -123,9 +123,8 @@ def main():
123 123 'review': args.review,
124 124 }
125 125  
126   - # testapp = App(config)
127 126 try:
128   - testapp = App(config)
  127 + app = App(config)
129 128 except AppException:
130 129 logging.critical('Failed to start application.')
131 130 sys.exit(-1)
... ... @@ -145,8 +144,7 @@ def main():
145 144 sys.exit(-1)
146 145  
147 146 # --- run webserver ----------------------------------------------------
148   - run_webserver(app=testapp, ssl_opt=ssl_opt, port=args.port,
149   - debug=args.debug)
  147 + run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug)
150 148  
151 149  
152 150 # ----------------------------------------------------------------------------
... ...
perguntations/parser_markdown.py
  1 +
1 2 '''
2 3 Parse markdown and generate HTML
3 4 Includes support for LaTeX formulas
... ... @@ -25,12 +26,19 @@ logger = logging.getLogger(__name__)
25 26 # Block math: $$x$$ or \begin{equation}x\end{equation}
26 27 # -------------------------------------------------------------------------
27 28 class MathBlockGrammar(mistune.BlockGrammar):
  29 + '''
  30 + match block math $$x$$ and math environments begin{} end{}
  31 + '''
  32 + # pylint: disable=too-few-public-methods
28 33 block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL)
29 34 latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}",
30 35 re.DOTALL)
31 36  
32 37  
33 38 class MathBlockLexer(mistune.BlockLexer):
  39 + '''
  40 + parser for block math and latex environment
  41 + '''
34 42 default_rules = ['block_math', 'latex_environment'] \
35 43 + mistune.BlockLexer.default_rules
36 44  
... ... @@ -56,12 +64,19 @@ class MathBlockLexer(mistune.BlockLexer):
56 64  
57 65  
58 66 class MathInlineGrammar(mistune.InlineGrammar):
  67 + '''
  68 + match inline math $x$, block math $$x$$ and text
  69 + '''
  70 + # pylint: disable=too-few-public-methods
59 71 math = re.compile(r"^\$(.+?)\$", re.DOTALL)
60 72 block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL)
61 73 text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)')
62 74  
63 75  
64 76 class MathInlineLexer(mistune.InlineLexer):
  77 + '''
  78 + render output math
  79 + '''
65 80 default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules
66 81  
67 82 def __init__(self, renderer, rules=None, **kwargs):
... ... @@ -70,13 +85,18 @@ class MathInlineLexer(mistune.InlineLexer):
70 85 super().__init__(renderer, rules, **kwargs)
71 86  
72 87 def output_math(self, math):
  88 + '''render inline math'''
73 89 return self.renderer.inline_math(math.group(1))
74 90  
75 91 def output_block_math(self, math):
  92 + '''render block math'''
76 93 return self.renderer.block_math(math.group(1))
77 94  
78 95  
79 96 class MarkdownWithMath(mistune.Markdown):
  97 + '''
  98 + render ouput latex
  99 + '''
80 100 def __init__(self, renderer, **kwargs):
81 101 if 'inline' not in kwargs:
82 102 kwargs['inline'] = MathInlineLexer
... ... @@ -85,19 +105,25 @@ class MarkdownWithMath(mistune.Markdown):
85 105 super().__init__(renderer, **kwargs)
86 106  
87 107 def output_block_math(self):
  108 + '''render block math'''
88 109 return self.renderer.block_math(self.token['text'])
89 110  
90 111 def output_latex_environment(self):
  112 + '''render latex environment'''
91 113 return self.renderer.latex_environment(self.token['name'],
92 114 self.token['text'])
93 115  
94 116  
95 117 class HighlightRenderer(mistune.Renderer):
  118 + '''
  119 + images, tables, block code
  120 + '''
96 121 def __init__(self, qref='.'):
97 122 super().__init__(escape=True)
98 123 self.qref = qref
99 124  
100 125 def block_code(self, code, lang='text'):
  126 + '''render code block with syntax highlight'''
101 127 try:
102 128 lexer = get_lexer_by_name(lang, stripall=False)
103 129 except Exception:
... ... @@ -107,6 +133,7 @@ class HighlightRenderer(mistune.Renderer):
107 133 return highlight(code, lexer, formatter)
108 134  
109 135 def table(self, header, body):
  136 + '''render table'''
110 137 return '<table class="table table-sm"><thead class="thead-light">' \
111 138 + header + '</thead><tbody>' + body + '</tbody></table>'
112 139  
... ... @@ -141,14 +168,17 @@ class HighlightRenderer(mistune.Renderer):
141 168 # Pass math through unaltered - mathjax does the rendering in the browser
142 169 def block_math(self, text):
143 170 '''bypass block math'''
  171 + # pylint: disable=no-self-use
144 172 return fr'$$ {text} $$'
145 173  
146 174 def latex_environment(self, name, text):
147 175 '''bypass latex environment'''
  176 + # pylint: disable=no-self-use
148 177 return fr'\begin{{{name}}} {text} \end{{{name}}}'
149 178  
150 179 def inline_math(self, text):
151 180 '''bypass inline math'''
  181 + # pylint: disable=no-self-use
152 182 return fr'$$$ {text} $$$'
153 183  
154 184  
... ...
perguntations/questions.py
... ... @@ -13,7 +13,7 @@ from typing import Any, Dict, NewType
13 13 import uuid
14 14  
15 15 # this project
16   -from .tools import run_script, run_script_async
  16 +from perguntations.tools import run_script, run_script_async
17 17  
18 18 # setup logger for this module
19 19 logger = logging.getLogger(__name__)
... ... @@ -110,10 +110,10 @@ class QuestionRadio(Question):
110 110 # make sure is a list of floats
111 111 try:
112 112 self['correct'] = [float(x) for x in self['correct']]
113   - except (ValueError, TypeError):
  113 + except (ValueError, TypeError) as exc:
114 114 msg = (f'Correct list must contain numbers [0.0, 1.0] or '
115 115 f'booleans in "{self["ref"]}"')
116   - raise QuestionException(msg)
  116 + raise QuestionException(msg) from exc
117 117  
118 118 # check grade boundaries
119 119 if self['discount'] and not all(0.0 <= x <= 1.0
... ... @@ -217,10 +217,10 @@ class QuestionCheckbox(Question):
217 217 # make sure is a list of floats
218 218 try:
219 219 self['correct'] = [float(x) for x in self['correct']]
220   - except (ValueError, TypeError):
  220 + except (ValueError, TypeError) as exc:
221 221 msg = (f'Correct list must contain numbers or '
222 222 f'booleans in "{self["ref"]}"')
223   - raise QuestionException(msg)
  223 + raise QuestionException(msg) from exc
224 224  
225 225 # check grade boundaries
226 226 if self['discount'] and not all(0.0 <= x <= 1.0
... ... @@ -379,9 +379,9 @@ class QuestionTextRegex(Question):
379 379 # converts patterns to compiled versions
380 380 try:
381 381 self['correct'] = [re.compile(a) for a in self['correct']]
382   - except Exception:
  382 + except Exception as exc:
383 383 msg = f'Failed to compile regex in "{self["ref"]}"'
384   - raise QuestionException(msg)
  384 + raise QuestionException(msg) from exc
385 385  
386 386 # ------------------------------------------------------------------------
387 387 def correct(self) -> None:
... ... @@ -430,10 +430,10 @@ class QuestionNumericInterval(Question):
430 430  
431 431 try:
432 432 self['correct'] = [float(n) for n in self['correct']]
433   - except Exception:
  433 + except Exception as exc:
434 434 msg = (f'Numeric interval must be a list with two numbers, in '
435 435 f'{self["ref"]}')
436   - raise QuestionException(msg)
  436 + raise QuestionException(msg) from exc
437 437  
438 438 # invalid
439 439 else:
... ...
perguntations/serve.py
1 1 #!/usr/bin/env python3
2 2  
3 3 '''
4   -Handles the web and html part of the application interface.
5   -The tornadoweb framework is used.
  4 +Handles the web, http & html part of the application interface.
  5 +Uses the tornadoweb framework.
6 6 '''
7 7  
8 8  
... ... @@ -40,8 +40,8 @@ class WebApplication(tornado.web.Application):
40 40 (r'/review', ReviewHandler),
41 41 (r'/admin', AdminHandler),
42 42 (r'/file', FileHandler),
43   - # (r'/root', MainHandler), # FIXME
44   - # (r'/ws', AdminSocketHandler),
  43 + # (r'/root', MainHandler),
  44 + # (r'/ws', AdminSocketHandler),
45 45 (r'/adminwebservice', AdminWebservice),
46 46 (r'/studentwebservice', StudentWebservice),
47 47 (r'/', RootHandler),
... ... @@ -66,7 +66,7 @@ def admin_only(func):
66 66 Decorator used to restrict access to the administrator.
67 67 Example:
68 68  
69   - @admin_only()
  69 + @admin_only
70 70 def get(self): ...
71 71 '''
72 72 @functools.wraps(func)
... ... @@ -78,6 +78,7 @@ def admin_only(func):
78 78  
79 79  
80 80 # ----------------------------------------------------------------------------
  81 +# pylint: disable=abstract-method
81 82 class BaseHandler(tornado.web.RequestHandler):
82 83 '''
83 84 Handlers should inherit this one instead of tornado.web.RequestHandler.
... ... @@ -87,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler):
87 88  
88 89 @property
89 90 def testapp(self):
90   - '''simplifies access to the application'''
  91 + '''simplifies access to the application a little bit'''
91 92 return self.application.testapp
92 93  
93 94 def get_current_user(self):
... ... @@ -158,6 +159,8 @@ class BaseHandler(tornado.web.RequestHandler):
158 159 # AdminSocketHandler.update_cache(chat) # store msgs
159 160 # AdminSocketHandler.send_updates(chat) # send to clients
160 161  
  162 +# ----------------------------------------------------------------------------
  163 +# pylint: disable=abstract-method
161 164 class StudentWebservice(BaseHandler):
162 165 '''
163 166 Receive ajax from students in the test in response from focus, unfocus and
... ... @@ -174,6 +177,7 @@ class StudentWebservice(BaseHandler):
174 177  
175 178  
176 179 # ----------------------------------------------------------------------------
  180 +# pylint: disable=abstract-method
177 181 class AdminWebservice(BaseHandler):
178 182 '''
179 183 Receive ajax requests from admin
... ... @@ -202,6 +206,7 @@ class AdminWebservice(BaseHandler):
202 206  
203 207  
204 208 # ----------------------------------------------------------------------------
  209 +# pylint: disable=abstract-method
205 210 class AdminHandler(BaseHandler):
206 211 '''Handle /admin'''
207 212  
... ... @@ -260,6 +265,7 @@ class AdminHandler(BaseHandler):
260 265  
261 266  
262 267 # ----------------------------------------------------------------------------
  268 +# pylint: disable=abstract-method
263 269 class LoginHandler(BaseHandler):
264 270 '''Handle /login'''
265 271  
... ... @@ -282,6 +288,7 @@ class LoginHandler(BaseHandler):
282 288  
283 289  
284 290 # ----------------------------------------------------------------------------
  291 +# pylint: disable=abstract-method
285 292 class LogoutHandler(BaseHandler):
286 293 '''Handle /logout'''
287 294  
... ... @@ -296,6 +303,7 @@ class LogoutHandler(BaseHandler):
296 303  
297 304  
298 305 # ----------------------------------------------------------------------------
  306 +# pylint: disable=abstract-method
299 307 class RootHandler(BaseHandler):
300 308 '''
301 309 Handles / to redirect students and admin to /test and /admin, resp.
... ... @@ -315,6 +323,7 @@ class RootHandler(BaseHandler):
315 323 # ----------------------------------------------------------------------------
316 324 # Serves files from the /public subdir of the topics.
317 325 # ----------------------------------------------------------------------------
  326 +# pylint: disable=abstract-method
318 327 class FileHandler(BaseHandler):
319 328 '''
320 329 Handles static files from questions like images, etc.
... ... @@ -366,6 +375,7 @@ class FileHandler(BaseHandler):
366 375 # ----------------------------------------------------------------------------
367 376 # Test shown to students
368 377 # ----------------------------------------------------------------------------
  378 +# pylint: disable=abstract-method
369 379 class TestHandler(BaseHandler):
370 380 '''
371 381 Generates test to student.
... ... @@ -373,6 +383,7 @@ class TestHandler(BaseHandler):
373 383 '''
374 384  
375 385 _templates = {
  386 + # -- question templates --
376 387 'radio': 'question-radio.html',
377 388 'checkbox': 'question-checkbox.html',
378 389 'text': 'question-text.html',
... ... @@ -396,6 +407,7 @@ class TestHandler(BaseHandler):
396 407 test = self.testapp.get_student_test(uid) # reloading returns same test
397 408 if test is None:
398 409 test = await self.testapp.generate_test(uid)
  410 +
399 411 self.render('test.html', t=test, md=md_to_html, templ=self._templates)
400 412  
401 413 # --- POST
... ... @@ -438,20 +450,8 @@ class TestHandler(BaseHandler):
438 450 self.render('grade.html', t=test, allgrades=allgrades)
439 451  
440 452  
441   -# ----------------------------------------------------------------------------
442   -# FIXME should be a post in the test with command giveup instead of correct...
443   -# class GiveupHandler(BaseHandler):
444   -# @tornado.web.authenticated
445   -# def get(self):
446   -# uid = self.current_user
447   -# t = self.testapp.giveup_test(uid)
448   -# self.testapp.logout(uid)
449   -
450   -# # --- Show result to student
451   -# self.render('grade.html', t=t, allgrades=self.testapp.get_student_grades_from_all_tests(uid))
452   -
453   -
454 453 # --- REVIEW -----------------------------------------------------------------
  454 +# pylint: disable=abstract-method
455 455 class ReviewHandler(BaseHandler):
456 456 '''
457 457 Show test for review
... ... @@ -488,18 +488,20 @@ class ReviewHandler(BaseHandler):
488 488 with open(path.expanduser(fname)) as jsonfile:
489 489 test = json.load(jsonfile)
490 490 except OSError:
491   - logging.error('Cannot open "%s" for review.', fname)
492   - raise tornado.web.HTTPError(404) # Not Found
  491 + msg = f'Cannot open "{fname}" for review.'
  492 + logging.error(msg)
  493 + raise tornado.web.HTTPError(status_code=404, reason=msg) from None
493 494 except json.JSONDecodeError as exc:
494   - logging.error('JSON error in "%s": %s', fname, exc)
495   - raise tornado.web.HTTPError(404) # Not Found
  495 + msg = f'JSON error in "{fname}": {exc}'
  496 + logging.error(msg)
  497 + raise tornado.web.HTTPError(status_code=404, reason=msg)
496 498  
497 499 self.render('review.html', t=test, md=md_to_html,
498   - templ=self._templates)
  500 + templ=self._templates)
499 501  
500 502  
501 503 # ----------------------------------------------------------------------------
502   -def signal_handler(sig, frame):
  504 +def signal_handler(*_):
503 505 '''
504 506 Catches Ctrl-C and stops webserver
505 507 '''
... ...
perguntations/static/js/admin.js
... ... @@ -119,7 +119,7 @@ $(document).ready(function() {
119 119 var checked = d['allowed'] ? 'checked' : '';
120 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   - var unfocus = d['unfocus']? ' <span class="badge badge-danger">unfocus</span>' : '';
  122 + var unfocus = d['unfocus'] ? ' <span class="badge badge-danger">unfocus</span>' : '';
123 123 var area = '';
124 124 if (d['start_time'] ) {
125 125 if (d['area'] > 75)
... ...
perguntations/templates/test.html
... ... @@ -44,7 +44,7 @@
44 44 <!-- ===================================================================== -->
45 45 <body>
46 46 <!-- ===================================================================== -->
47   -<div class="progress fixed-top" style="height: 60px; border-radius: 0px;">
  47 +<div class="progress fixed-top" style="height: 61px; border-radius: 0px;">
48 48 <div class="progress-bar bg-secondary" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div>
49 49 </div>
50 50  
... ...
perguntations/test.py
... ... @@ -111,9 +111,9 @@ class TestFactory(dict):
111 111 # check if all the questions can be correctly generated
112 112 try:
113 113 self.question_factory[question['ref']].generate()
114   - except Exception:
  114 + except Exception as exc:
115 115 msg = f'Failed to generate "{question["ref"]}"'
116   - raise TestFactoryException(msg)
  116 + raise TestFactoryException(msg) from exc
117 117 else:
118 118 logger.info('%4d. "%s" Ok.', counter, question["ref"])
119 119 counter += 1
... ... @@ -152,9 +152,9 @@ class TestFactory(dict):
152 152 try:
153 153 with open(testfile, 'w') as file:
154 154 file.write('You can safely remove this file.')
155   - except OSError:
  155 + except OSError as exc:
156 156 msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
157   - raise TestFactoryException(msg)
  157 + raise TestFactoryException(msg) from exc
158 158  
159 159 def check_questions_directory(self):
160 160 '''Check if questions directory is missing or not accessible.'''
... ... @@ -223,16 +223,16 @@ class TestFactory(dict):
223 223 self.check_grade_scaling()
224 224  
225 225 # ------------------------------------------------------------------------
226   - async def generate(self, student):
  226 + async def generate(self): #, student):
227 227 '''
228 228 Given a dictionary with a student dict {'name':'john', 'number': 123}
229 229 returns instance of Test() for that particular student
230 230 '''
231 231  
232 232 # make list of questions
233   - test = []
234   - qnum = 1 # track question number
235   - nerr = 0 # count errors generating questions
  233 + questions = []
  234 + qnum = 1 # track question number
  235 + nerr = 0 # count errors during questions generation
236 236  
237 237 for qlist in self['questions']:
238 238 # choose one question variant
... ... @@ -255,28 +255,28 @@ class TestFactory(dict):
255 255 question['number'] = qnum # counter for non informative panels
256 256 qnum += 1
257 257  
258   - test.append(question)
  258 + questions.append(question)
259 259  
260 260 # setup scale
261   - total_points = sum(q['points'] for q in test)
  261 + total_points = sum(q['points'] for q in questions)
262 262  
263 263 if total_points > 0:
264 264 # normalize question points to scale
265 265 if self['scale'] is not None:
266 266 scale_min, scale_max = self['scale']
267   - for question in test:
  267 + for question in questions:
268 268 question['points'] *= (scale_max - scale_min) / total_points
269 269 else:
270 270 self['scale'] = [0, total_points]
271 271 else:
272 272 logger.warning('Total points is **ZERO**.')
273 273 if self['scale'] is None:
274   - self['scale'] = [0, 20]
  274 + self['scale'] = [0, 20] # default
275 275  
276 276 if nerr > 0:
277 277 logger.error('%s errors found!', nerr)
278 278  
279   - # these will be copied to the test instance
  279 + # copy these from the test configuratoin to each test instance
280 280 inherit = {'ref', 'title', 'database', 'answers_dir',
281 281 'questions_dir', 'files',
282 282 'duration', 'autosubmit',
... ... @@ -284,9 +284,7 @@ class TestFactory(dict):
284 284 'show_ref', 'debug', }
285 285 # NOT INCLUDED: testfile, allow_all, review
286 286  
287   - return Test({
288   - **{'student': student, 'questions': test},
289   - **{k:self[k] for k in inherit}})
  287 + return Test({'questions': questions, **{k:self[k] for k in inherit}})
290 288  
291 289 # ------------------------------------------------------------------------
292 290 def __repr__(self):
... ... @@ -301,8 +299,15 @@ class Test(dict):
301 299 '''
302 300  
303 301 # ------------------------------------------------------------------------
304   - def __init__(self, d):
305   - super().__init__(d)
  302 + # def __init__(self, d):
  303 + # super().__init__(d)
  304 +
  305 + # ------------------------------------------------------------------------
  306 + def start(self, student):
  307 + '''
  308 + Write student id in the test and register start time
  309 + '''
  310 + self['student'] = student
306 311 self['start_time'] = datetime.now()
307 312 self['finish_time'] = None
308 313 self['state'] = 'ACTIVE'
... ... @@ -346,5 +351,12 @@ class Test(dict):
346 351 self['finish_time'] = datetime.now()
347 352 self['state'] = 'QUIT'
348 353 self['grade'] = 0.0
349   - logger.info('Student %s: gave up.', self["student"]["number"])
  354 + # logger.info('Student %s: gave up.', self["student"]["number"])
350 355 return self['grade']
  356 +
  357 + # ------------------------------------------------------------------------
  358 + def __str__(self):
  359 + return ('Test:\n'
  360 + f' student: {self.get("student", "--")}\n'
  361 + f' start_time: {self.get("start_time", "--")}\n'
  362 + f' questions: {", ".join(q["ref"] for q in self["questions"])}\n')
... ...