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
@@ -39,7 +39,7 @@ This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in @@ -39,7 +39,7 @@ This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in
39 Download and install: 39 Download and install:
40 40
41 ```sh 41 ```sh
42 -git clone https://git.xdi.uevora.pt/perguntations.git 42 +git clone https://git.xdi.uevora.pt/mjsb/perguntations.git
43 cd perguntations 43 cd perguntations
44 npm install 44 npm install
45 pip3 install . 45 pip3 install .
@@ -225,7 +225,7 @@ Python packages can be upgraded independently of the rest using pip: @@ -225,7 +225,7 @@ Python packages can be upgraded independently of the rest using pip:
225 225
226 ```sh 226 ```sh
227 pip list --outdated # lists upgradable packages 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 To upgrade perguntations and javascript libraries do: 231 To upgrade perguntations and javascript libraries do:
demo/demo.yaml
@@ -22,7 +22,7 @@ title: Teste de demonstração (tutorial) @@ -22,7 +22,7 @@ title: Teste de demonstração (tutorial)
22 22
23 # Duration in minutes. 23 # Duration in minutes.
24 # (0 or undefined means infinite time) 24 # (0 or undefined means infinite time)
25 -duration: 5 25 +duration: 20
26 26
27 # Automatic test submission after the given 'duration' timeout 27 # Automatic test submission after the given 'duration' timeout
28 # (default: false) 28 # (default: false)
@@ -37,11 +37,6 @@ show_points: true @@ -37,11 +37,6 @@ show_points: true
37 # (default: no scaling, just use question points) 37 # (default: no scaling, just use question points)
38 scale: [0, 5] 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 # Base path applied to the questions files and all the scripts 41 # Base path applied to the questions files and all the scripts
47 # including question generators and correctors. 42 # including question generators and correctors.
demo/questions/generators/generate-question.py
@@ -8,6 +8,7 @@ Arguments are read from stdin. @@ -8,6 +8,7 @@ Arguments are read from stdin.
8 from random import randint 8 from random import randint
9 import sys 9 import sys
10 10
  11 +# read two arguments from the field `args` specified in the question yaml file
11 a, b = (int(n) for n in sys.argv[1:]) 12 a, b = (int(n) for n in sys.argv[1:])
12 13
13 x = randint(a, b) 14 x = randint(a, b)
@@ -18,10 +19,13 @@ print(f"""--- @@ -18,10 +19,13 @@ print(f"""---
18 type: text 19 type: text
19 title: Geradores de perguntas 20 title: Geradores de perguntas
20 text: | 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 ```python 30 ```python
27 #!/usr/bin/env python3 31 #!/usr/bin/env python3
@@ -46,7 +50,7 @@ text: | @@ -46,7 +50,7 @@ text: |
46 ``` 50 ```
47 51
48 Este script deve ter permissões para poder ser executado no terminal. 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 o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar 54 o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar
51 que este script deve ser usado para gerar uma pergunta. 55 que este script deve ser usado para gerar uma pergunta.
52 56
@@ -60,7 +64,8 @@ text: | @@ -60,7 +64,8 @@ text: |
60 args: [1, 100] 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,9 +24,8 @@
24 duration: 60 # duração da prova em minutos (default: inf) 24 duration: 60 # duração da prova em minutos (default: inf)
25 autosubmit: true # submissão automática (default: false) 25 autosubmit: true # submissão automática (default: false)
26 show_points: true # mostra cotação das perguntas (default: true) 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 debug: false # mostra informação de debug no browser 29 debug: false # mostra informação de debug no browser
31 30
32 # -------------------------------------------------------------------------- 31 # --------------------------------------------------------------------------
@@ -48,9 +47,9 @@ @@ -48,9 +47,9 @@
48 points: 3.5 47 points: 3.5
49 48
50 - ref: pergunta2 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 - ref: pergunta3 53 - ref: pergunta3
55 54
56 # uma string (não dict), é interpretada como referência 55 # uma string (não dict), é interpretada como referência
@@ -153,17 +152,19 @@ @@ -153,17 +152,19 @@
153 entre 0 e 1, sendo atribuída a respectiva cotação, mas só o valor 1 152 entre 0 e 1, sendo atribuída a respectiva cotação, mas só o valor 1
154 representa uma opção certa. 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 ```yaml 159 ```yaml
  160 + correct: [0, 0, 1, 0, 0]
160 shuffle: false 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 ```yaml 169 ```yaml
169 discount: false 170 discount: false
@@ -269,7 +270,7 @@ @@ -269,7 +270,7 @@
269 Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`. 270 Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`.
270 271
271 Em alguns casos pode ser conveniente transformar a resposta antes de a 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 A opção `transform` permite dar uma sequência de transformações a aplicar à 274 A opção `transform` permite dar uma sequência de transformações a aplicar à
274 resposta do aluno, por exemplo: 275 resposta do aluno, por exemplo:
275 276
@@ -278,10 +279,10 @@ @@ -278,10 +279,10 @@
278 correct: ['azul'] 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 * `remove_space` remove todos os espaços (início, meio e fim). 286 * `remove_space` remove todos os espaços (início, meio e fim).
286 * `normalize_space` remove espaços do início e fim (trim), e substitui 287 * `normalize_space` remove espaços do início e fim (trim), e substitui
287 múltiplos espaços por um único espaço (no meio). 288 múltiplos espaços por um único espaço (no meio).
@@ -375,10 +376,11 @@ @@ -375,10 +376,11 @@
375 são as mais flexíveis. 376 são as mais flexíveis.
376 377
377 A resposta é enviada para um programa externo para ser avaliada. 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 Exemplo: 384 Exemplo:
383 385
384 ```yaml 386 ```yaml
@@ -566,7 +568,7 @@ @@ -566,7 +568,7 @@
566 duas possibilidads: 568 duas possibilidads:
567 569
568 - Imagens inline: não têm título definido e podem ser incluídas no meio de 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 - Imagens centradas com título: `![alt text](image.jpg "Título da imagem")`. 572 - Imagens centradas com título: `![alt text](image.jpg "Título da imagem")`.
571 O título é colocado por baixo da imagem. Pode ser uma string vazia. 573 O título é colocado por baixo da imagem. Pode ser uma string vazia.
572 574
@@ -2,13 +2,13 @@ @@ -2,13 +2,13 @@
2 "description": "Javascript libraries required to run the server", 2 "description": "Javascript libraries required to run the server",
3 "email": "mjsb@uevora.pt", 3 "email": "mjsb@uevora.pt",
4 "dependencies": { 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 "datatables": "^1.10", 8 "datatables": "^1.10",
9 "jquery": "^3.5.1", 9 "jquery": "^3.5.1",
10 - "mathjax": "^3.0.5", 10 + "mathjax": "^3.1.2",
11 "popper.js": "^1.16.1", 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,7 +32,7 @@ proof of submission and for review.
32 ''' 32 '''
33 33
34 APP_NAME = 'perguntations' 34 APP_NAME = 'perguntations'
35 -APP_VERSION = '2020.05.dev6' 35 +APP_VERSION = '2020.11.dev1'
36 APP_DESCRIPTION = __doc__ 36 APP_DESCRIPTION = __doc__
37 37
38 __author__ = 'Miguel Barão' 38 __author__ = 'Miguel Barão'
perguntations/app.py
@@ -88,24 +88,9 @@ class App(): @@ -88,24 +88,9 @@ class App():
88 self.allowed = set() # '0' is hardcoded to allowed elsewhere 88 self.allowed = set() # '0' is hardcoded to allowed elsewhere
89 self.unfocus = set() # set of students that have no browser focus 89 self.unfocus = set() # set of students that have no browser focus
90 self.area = dict() # {uid: percent_area} 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 # connect to database and check registered students 95 # connect to database and check registered students
111 dbfile = self.testfactory['database'] 96 dbfile = self.testfactory['database']
@@ -115,28 +100,22 @@ class App(): @@ -115,28 +100,22 @@ class App():
115 try: 100 try:
116 with self.db_session() as sess: 101 with self.db_session() as sess:
117 num = sess.query(Student).filter(Student.id != '0').count() 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 # command line option --allow-all 112 # command line option --allow-all
124 if conf['allow_all']: 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 else: 115 else:
129 logger.info('Students not yet allowed to login.') 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 async def login(self, uid, try_pw): 119 async def login(self, uid, try_pw):
141 '''login authentication''' 120 '''login authentication'''
142 if uid not in self.allowed and uid != '0': # not allowed 121 if uid not in self.allowed and uid != '0': # not allowed
@@ -175,18 +154,60 @@ class App(): @@ -175,18 +154,60 @@ class App():
175 logger.info('"%s" logged out.', uid) 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 async def generate_test(self, uid): 192 async def generate_test(self, uid):
179 '''generate a test for a given student''' 193 '''generate a test for a given student'''
180 if uid in self.online: 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 student_id = self.online[uid]['student'] # {number, name} 203 student_id = self.online[uid]['student'] # {number, name}
183 - test = await self.testfactory.generate(student_id) 204 + test.start(student_id)
184 self.online[uid]['test'] = test 205 self.online[uid]['test'] = test
185 logger.info('"%s" test is ready.', uid) 206 logger.info('"%s" test is ready.', uid)
186 return self.online[uid]['test'] 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 async def correct_test(self, uid, ans): 213 async def correct_test(self, uid, ans):
@@ -271,11 +292,11 @@ class App(): @@ -271,11 +292,11 @@ class App():
271 '''handles browser events the occur during the test''' 292 '''handles browser events the occur during the test'''
272 if cmd == 'focus': 293 if cmd == 'focus':
273 if value: 294 if value:
274 - self.focus_student(uid) 295 + self._focus_student(uid)
275 else: 296 else:
276 - self.unfocus_student(uid) 297 + self._unfocus_student(uid)
277 elif cmd == 'size': 298 elif cmd == 'size':
278 - self.set_screen_area(uid, value) 299 + self._set_screen_area(uid, value)
279 300
280 # ------------------------------------------------------------------------ 301 # ------------------------------------------------------------------------
281 # --- GETTERS 302 # --- GETTERS
@@ -297,11 +318,11 @@ class App(): @@ -297,11 +318,11 @@ class App():
297 318
298 cols = ['Aluno', 'Início'] + \ 319 cols = ['Aluno', 'Início'] + \
299 [r for question in self.testfactory['questions'] 320 [r for question in self.testfactory['questions']
300 - for r in question['ref']] 321 + for r in question['ref']]
301 322
302 tests = {} 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 tests.setdefault(student, {})[qref] = qgrade 326 tests.setdefault(student, {})[qref] = qgrade
306 327
307 rows = [{'Aluno': test[0], 'Início': test[1], **q} 328 rows = [{'Aluno': test[0], 'Início': test[1], **q}
@@ -351,13 +372,6 @@ class App(): @@ -351,13 +372,6 @@ class App():
351 .filter_by(id=test_id)\ 372 .filter_by(id=test_id)\
352 .scalar() 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 def get_student_grades_from_test(self, uid, testid): 375 def get_student_grades_from_test(self, uid, testid):
362 '''get grades of student for a given testid''' 376 '''get grades of student for a given testid'''
363 with self.db_session() as sess: 377 with self.db_session() as sess:
@@ -380,7 +394,15 @@ class App(): @@ -380,7 +394,15 @@ class App():
380 'area': self.area.get(uid, None), 394 'area': self.area.get(uid, None),
381 'grades': self.get_student_grades_from_test( 395 'grades': self.get_student_grades_from_test(
382 uid, self.testfactory['ref']) 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 # def get_allowed_students(self): 407 # def get_allowed_students(self):
386 # # set of 'uid' allowed to login 408 # # set of 'uid' allowed to login
@@ -409,30 +431,31 @@ class App(): @@ -409,30 +431,31 @@ class App():
409 431
410 def allow_all_students(self): 432 def allow_all_students(self):
411 '''allow all students to login''' 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 def deny_all_students(self): 438 def deny_all_students(self):
416 '''deny all students to login''' 439 '''deny all students to login'''
417 logger.info('Denying all students...') 440 logger.info('Denying all students...')
418 self.allowed.clear() 441 self.allowed.clear()
419 442
420 - def focus_student(self, uid): 443 + def _focus_student(self, uid):
421 '''set student in focus state''' 444 '''set student in focus state'''
422 self.unfocus.discard(uid) 445 self.unfocus.discard(uid)
423 logger.info('"%s" focus', uid) 446 logger.info('"%s" focus', uid)
424 447
425 - def unfocus_student(self, uid): 448 + def _unfocus_student(self, uid):
426 '''set student in unfocus state''' 449 '''set student in unfocus state'''
427 self.unfocus.add(uid) 450 self.unfocus.add(uid)
428 logger.info('"%s" unfocus', uid) 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 '''set current browser area as detected in resize event''' 454 '''set current browser area as detected in resize event'''
432 scr_y, scr_x, win_y, win_x = sizes 455 scr_y, scr_x, win_y, win_x = sizes
433 area = win_x * win_y / (scr_x * scr_y) * 100 456 area = win_x * win_y / (scr_x * scr_y) * 100
434 self.area[uid] = area 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 uid, area, win_x, win_y, scr_x, scr_y) 459 uid, area, win_x, win_y, scr_x, scr_y)
437 460
438 async def update_student_password(self, uid, password=''): 461 async def update_student_password(self, uid, password=''):
perguntations/initdb.py
1 #!/usr/bin/env python3 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 # base 7 # base
perguntations/main.py
@@ -15,10 +15,10 @@ import sys @@ -15,10 +15,10 @@ import sys
15 # from typing import Any, Dict 15 # from typing import Any, Dict
16 16
17 # this project 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,9 +123,8 @@ def main():
123 'review': args.review, 123 'review': args.review,
124 } 124 }
125 125
126 - # testapp = App(config)  
127 try: 126 try:
128 - testapp = App(config) 127 + app = App(config)
129 except AppException: 128 except AppException:
130 logging.critical('Failed to start application.') 129 logging.critical('Failed to start application.')
131 sys.exit(-1) 130 sys.exit(-1)
@@ -145,8 +144,7 @@ def main(): @@ -145,8 +144,7 @@ def main():
145 sys.exit(-1) 144 sys.exit(-1)
146 145
147 # --- run webserver ---------------------------------------------------- 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 Parse markdown and generate HTML 3 Parse markdown and generate HTML
3 Includes support for LaTeX formulas 4 Includes support for LaTeX formulas
@@ -25,12 +26,19 @@ logger = logging.getLogger(__name__) @@ -25,12 +26,19 @@ logger = logging.getLogger(__name__)
25 # Block math: $$x$$ or \begin{equation}x\end{equation} 26 # Block math: $$x$$ or \begin{equation}x\end{equation}
26 # ------------------------------------------------------------------------- 27 # -------------------------------------------------------------------------
27 class MathBlockGrammar(mistune.BlockGrammar): 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 block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) 33 block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL)
29 latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", 34 latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}",
30 re.DOTALL) 35 re.DOTALL)
31 36
32 37
33 class MathBlockLexer(mistune.BlockLexer): 38 class MathBlockLexer(mistune.BlockLexer):
  39 + '''
  40 + parser for block math and latex environment
  41 + '''
34 default_rules = ['block_math', 'latex_environment'] \ 42 default_rules = ['block_math', 'latex_environment'] \
35 + mistune.BlockLexer.default_rules 43 + mistune.BlockLexer.default_rules
36 44
@@ -56,12 +64,19 @@ class MathBlockLexer(mistune.BlockLexer): @@ -56,12 +64,19 @@ class MathBlockLexer(mistune.BlockLexer):
56 64
57 65
58 class MathInlineGrammar(mistune.InlineGrammar): 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 math = re.compile(r"^\$(.+?)\$", re.DOTALL) 71 math = re.compile(r"^\$(.+?)\$", re.DOTALL)
60 block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL) 72 block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL)
61 text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)') 73 text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)')
62 74
63 75
64 class MathInlineLexer(mistune.InlineLexer): 76 class MathInlineLexer(mistune.InlineLexer):
  77 + '''
  78 + render output math
  79 + '''
65 default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules 80 default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules
66 81
67 def __init__(self, renderer, rules=None, **kwargs): 82 def __init__(self, renderer, rules=None, **kwargs):
@@ -70,13 +85,18 @@ class MathInlineLexer(mistune.InlineLexer): @@ -70,13 +85,18 @@ class MathInlineLexer(mistune.InlineLexer):
70 super().__init__(renderer, rules, **kwargs) 85 super().__init__(renderer, rules, **kwargs)
71 86
72 def output_math(self, math): 87 def output_math(self, math):
  88 + '''render inline math'''
73 return self.renderer.inline_math(math.group(1)) 89 return self.renderer.inline_math(math.group(1))
74 90
75 def output_block_math(self, math): 91 def output_block_math(self, math):
  92 + '''render block math'''
76 return self.renderer.block_math(math.group(1)) 93 return self.renderer.block_math(math.group(1))
77 94
78 95
79 class MarkdownWithMath(mistune.Markdown): 96 class MarkdownWithMath(mistune.Markdown):
  97 + '''
  98 + render ouput latex
  99 + '''
80 def __init__(self, renderer, **kwargs): 100 def __init__(self, renderer, **kwargs):
81 if 'inline' not in kwargs: 101 if 'inline' not in kwargs:
82 kwargs['inline'] = MathInlineLexer 102 kwargs['inline'] = MathInlineLexer
@@ -85,19 +105,25 @@ class MarkdownWithMath(mistune.Markdown): @@ -85,19 +105,25 @@ class MarkdownWithMath(mistune.Markdown):
85 super().__init__(renderer, **kwargs) 105 super().__init__(renderer, **kwargs)
86 106
87 def output_block_math(self): 107 def output_block_math(self):
  108 + '''render block math'''
88 return self.renderer.block_math(self.token['text']) 109 return self.renderer.block_math(self.token['text'])
89 110
90 def output_latex_environment(self): 111 def output_latex_environment(self):
  112 + '''render latex environment'''
91 return self.renderer.latex_environment(self.token['name'], 113 return self.renderer.latex_environment(self.token['name'],
92 self.token['text']) 114 self.token['text'])
93 115
94 116
95 class HighlightRenderer(mistune.Renderer): 117 class HighlightRenderer(mistune.Renderer):
  118 + '''
  119 + images, tables, block code
  120 + '''
96 def __init__(self, qref='.'): 121 def __init__(self, qref='.'):
97 super().__init__(escape=True) 122 super().__init__(escape=True)
98 self.qref = qref 123 self.qref = qref
99 124
100 def block_code(self, code, lang='text'): 125 def block_code(self, code, lang='text'):
  126 + '''render code block with syntax highlight'''
101 try: 127 try:
102 lexer = get_lexer_by_name(lang, stripall=False) 128 lexer = get_lexer_by_name(lang, stripall=False)
103 except Exception: 129 except Exception:
@@ -107,6 +133,7 @@ class HighlightRenderer(mistune.Renderer): @@ -107,6 +133,7 @@ class HighlightRenderer(mistune.Renderer):
107 return highlight(code, lexer, formatter) 133 return highlight(code, lexer, formatter)
108 134
109 def table(self, header, body): 135 def table(self, header, body):
  136 + '''render table'''
110 return '<table class="table table-sm"><thead class="thead-light">' \ 137 return '<table class="table table-sm"><thead class="thead-light">' \
111 + header + '</thead><tbody>' + body + '</tbody></table>' 138 + header + '</thead><tbody>' + body + '</tbody></table>'
112 139
@@ -141,14 +168,17 @@ class HighlightRenderer(mistune.Renderer): @@ -141,14 +168,17 @@ class HighlightRenderer(mistune.Renderer):
141 # Pass math through unaltered - mathjax does the rendering in the browser 168 # Pass math through unaltered - mathjax does the rendering in the browser
142 def block_math(self, text): 169 def block_math(self, text):
143 '''bypass block math''' 170 '''bypass block math'''
  171 + # pylint: disable=no-self-use
144 return fr'$$ {text} $$' 172 return fr'$$ {text} $$'
145 173
146 def latex_environment(self, name, text): 174 def latex_environment(self, name, text):
147 '''bypass latex environment''' 175 '''bypass latex environment'''
  176 + # pylint: disable=no-self-use
148 return fr'\begin{{{name}}} {text} \end{{{name}}}' 177 return fr'\begin{{{name}}} {text} \end{{{name}}}'
149 178
150 def inline_math(self, text): 179 def inline_math(self, text):
151 '''bypass inline math''' 180 '''bypass inline math'''
  181 + # pylint: disable=no-self-use
152 return fr'$$$ {text} $$$' 182 return fr'$$$ {text} $$$'
153 183
154 184
perguntations/questions.py
@@ -13,7 +13,7 @@ from typing import Any, Dict, NewType @@ -13,7 +13,7 @@ from typing import Any, Dict, NewType
13 import uuid 13 import uuid
14 14
15 # this project 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 # setup logger for this module 18 # setup logger for this module
19 logger = logging.getLogger(__name__) 19 logger = logging.getLogger(__name__)
@@ -110,10 +110,10 @@ class QuestionRadio(Question): @@ -110,10 +110,10 @@ class QuestionRadio(Question):
110 # make sure is a list of floats 110 # make sure is a list of floats
111 try: 111 try:
112 self['correct'] = [float(x) for x in self['correct']] 112 self['correct'] = [float(x) for x in self['correct']]
113 - except (ValueError, TypeError): 113 + except (ValueError, TypeError) as exc:
114 msg = (f'Correct list must contain numbers [0.0, 1.0] or ' 114 msg = (f'Correct list must contain numbers [0.0, 1.0] or '
115 f'booleans in "{self["ref"]}"') 115 f'booleans in "{self["ref"]}"')
116 - raise QuestionException(msg) 116 + raise QuestionException(msg) from exc
117 117
118 # check grade boundaries 118 # check grade boundaries
119 if self['discount'] and not all(0.0 <= x <= 1.0 119 if self['discount'] and not all(0.0 <= x <= 1.0
@@ -217,10 +217,10 @@ class QuestionCheckbox(Question): @@ -217,10 +217,10 @@ class QuestionCheckbox(Question):
217 # make sure is a list of floats 217 # make sure is a list of floats
218 try: 218 try:
219 self['correct'] = [float(x) for x in self['correct']] 219 self['correct'] = [float(x) for x in self['correct']]
220 - except (ValueError, TypeError): 220 + except (ValueError, TypeError) as exc:
221 msg = (f'Correct list must contain numbers or ' 221 msg = (f'Correct list must contain numbers or '
222 f'booleans in "{self["ref"]}"') 222 f'booleans in "{self["ref"]}"')
223 - raise QuestionException(msg) 223 + raise QuestionException(msg) from exc
224 224
225 # check grade boundaries 225 # check grade boundaries
226 if self['discount'] and not all(0.0 <= x <= 1.0 226 if self['discount'] and not all(0.0 <= x <= 1.0
@@ -379,9 +379,9 @@ class QuestionTextRegex(Question): @@ -379,9 +379,9 @@ class QuestionTextRegex(Question):
379 # converts patterns to compiled versions 379 # converts patterns to compiled versions
380 try: 380 try:
381 self['correct'] = [re.compile(a) for a in self['correct']] 381 self['correct'] = [re.compile(a) for a in self['correct']]
382 - except Exception: 382 + except Exception as exc:
383 msg = f'Failed to compile regex in "{self["ref"]}"' 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 def correct(self) -> None: 387 def correct(self) -> None:
@@ -430,10 +430,10 @@ class QuestionNumericInterval(Question): @@ -430,10 +430,10 @@ class QuestionNumericInterval(Question):
430 430
431 try: 431 try:
432 self['correct'] = [float(n) for n in self['correct']] 432 self['correct'] = [float(n) for n in self['correct']]
433 - except Exception: 433 + except Exception as exc:
434 msg = (f'Numeric interval must be a list with two numbers, in ' 434 msg = (f'Numeric interval must be a list with two numbers, in '
435 f'{self["ref"]}') 435 f'{self["ref"]}')
436 - raise QuestionException(msg) 436 + raise QuestionException(msg) from exc
437 437
438 # invalid 438 # invalid
439 else: 439 else:
perguntations/serve.py
1 #!/usr/bin/env python3 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,8 +40,8 @@ class WebApplication(tornado.web.Application):
40 (r'/review', ReviewHandler), 40 (r'/review', ReviewHandler),
41 (r'/admin', AdminHandler), 41 (r'/admin', AdminHandler),
42 (r'/file', FileHandler), 42 (r'/file', FileHandler),
43 - # (r'/root', MainHandler), # FIXME  
44 - # (r'/ws', AdminSocketHandler), 43 + # (r'/root', MainHandler),
  44 + # (r'/ws', AdminSocketHandler),
45 (r'/adminwebservice', AdminWebservice), 45 (r'/adminwebservice', AdminWebservice),
46 (r'/studentwebservice', StudentWebservice), 46 (r'/studentwebservice', StudentWebservice),
47 (r'/', RootHandler), 47 (r'/', RootHandler),
@@ -66,7 +66,7 @@ def admin_only(func): @@ -66,7 +66,7 @@ def admin_only(func):
66 Decorator used to restrict access to the administrator. 66 Decorator used to restrict access to the administrator.
67 Example: 67 Example:
68 68
69 - @admin_only() 69 + @admin_only
70 def get(self): ... 70 def get(self): ...
71 ''' 71 '''
72 @functools.wraps(func) 72 @functools.wraps(func)
@@ -78,6 +78,7 @@ def admin_only(func): @@ -78,6 +78,7 @@ def admin_only(func):
78 78
79 79
80 # ---------------------------------------------------------------------------- 80 # ----------------------------------------------------------------------------
  81 +# pylint: disable=abstract-method
81 class BaseHandler(tornado.web.RequestHandler): 82 class BaseHandler(tornado.web.RequestHandler):
82 ''' 83 '''
83 Handlers should inherit this one instead of tornado.web.RequestHandler. 84 Handlers should inherit this one instead of tornado.web.RequestHandler.
@@ -87,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler): @@ -87,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler):
87 88
88 @property 89 @property
89 def testapp(self): 90 def testapp(self):
90 - '''simplifies access to the application''' 91 + '''simplifies access to the application a little bit'''
91 return self.application.testapp 92 return self.application.testapp
92 93
93 def get_current_user(self): 94 def get_current_user(self):
@@ -158,6 +159,8 @@ class BaseHandler(tornado.web.RequestHandler): @@ -158,6 +159,8 @@ class BaseHandler(tornado.web.RequestHandler):
158 # AdminSocketHandler.update_cache(chat) # store msgs 159 # AdminSocketHandler.update_cache(chat) # store msgs
159 # AdminSocketHandler.send_updates(chat) # send to clients 160 # AdminSocketHandler.send_updates(chat) # send to clients
160 161
  162 +# ----------------------------------------------------------------------------
  163 +# pylint: disable=abstract-method
161 class StudentWebservice(BaseHandler): 164 class StudentWebservice(BaseHandler):
162 ''' 165 '''
163 Receive ajax from students in the test in response from focus, unfocus and 166 Receive ajax from students in the test in response from focus, unfocus and
@@ -174,6 +177,7 @@ class StudentWebservice(BaseHandler): @@ -174,6 +177,7 @@ class StudentWebservice(BaseHandler):
174 177
175 178
176 # ---------------------------------------------------------------------------- 179 # ----------------------------------------------------------------------------
  180 +# pylint: disable=abstract-method
177 class AdminWebservice(BaseHandler): 181 class AdminWebservice(BaseHandler):
178 ''' 182 '''
179 Receive ajax requests from admin 183 Receive ajax requests from admin
@@ -202,6 +206,7 @@ class AdminWebservice(BaseHandler): @@ -202,6 +206,7 @@ class AdminWebservice(BaseHandler):
202 206
203 207
204 # ---------------------------------------------------------------------------- 208 # ----------------------------------------------------------------------------
  209 +# pylint: disable=abstract-method
205 class AdminHandler(BaseHandler): 210 class AdminHandler(BaseHandler):
206 '''Handle /admin''' 211 '''Handle /admin'''
207 212
@@ -260,6 +265,7 @@ class AdminHandler(BaseHandler): @@ -260,6 +265,7 @@ class AdminHandler(BaseHandler):
260 265
261 266
262 # ---------------------------------------------------------------------------- 267 # ----------------------------------------------------------------------------
  268 +# pylint: disable=abstract-method
263 class LoginHandler(BaseHandler): 269 class LoginHandler(BaseHandler):
264 '''Handle /login''' 270 '''Handle /login'''
265 271
@@ -282,6 +288,7 @@ class LoginHandler(BaseHandler): @@ -282,6 +288,7 @@ class LoginHandler(BaseHandler):
282 288
283 289
284 # ---------------------------------------------------------------------------- 290 # ----------------------------------------------------------------------------
  291 +# pylint: disable=abstract-method
285 class LogoutHandler(BaseHandler): 292 class LogoutHandler(BaseHandler):
286 '''Handle /logout''' 293 '''Handle /logout'''
287 294
@@ -296,6 +303,7 @@ class LogoutHandler(BaseHandler): @@ -296,6 +303,7 @@ class LogoutHandler(BaseHandler):
296 303
297 304
298 # ---------------------------------------------------------------------------- 305 # ----------------------------------------------------------------------------
  306 +# pylint: disable=abstract-method
299 class RootHandler(BaseHandler): 307 class RootHandler(BaseHandler):
300 ''' 308 '''
301 Handles / to redirect students and admin to /test and /admin, resp. 309 Handles / to redirect students and admin to /test and /admin, resp.
@@ -315,6 +323,7 @@ class RootHandler(BaseHandler): @@ -315,6 +323,7 @@ class RootHandler(BaseHandler):
315 # ---------------------------------------------------------------------------- 323 # ----------------------------------------------------------------------------
316 # Serves files from the /public subdir of the topics. 324 # Serves files from the /public subdir of the topics.
317 # ---------------------------------------------------------------------------- 325 # ----------------------------------------------------------------------------
  326 +# pylint: disable=abstract-method
318 class FileHandler(BaseHandler): 327 class FileHandler(BaseHandler):
319 ''' 328 '''
320 Handles static files from questions like images, etc. 329 Handles static files from questions like images, etc.
@@ -366,6 +375,7 @@ class FileHandler(BaseHandler): @@ -366,6 +375,7 @@ class FileHandler(BaseHandler):
366 # ---------------------------------------------------------------------------- 375 # ----------------------------------------------------------------------------
367 # Test shown to students 376 # Test shown to students
368 # ---------------------------------------------------------------------------- 377 # ----------------------------------------------------------------------------
  378 +# pylint: disable=abstract-method
369 class TestHandler(BaseHandler): 379 class TestHandler(BaseHandler):
370 ''' 380 '''
371 Generates test to student. 381 Generates test to student.
@@ -373,6 +383,7 @@ class TestHandler(BaseHandler): @@ -373,6 +383,7 @@ class TestHandler(BaseHandler):
373 ''' 383 '''
374 384
375 _templates = { 385 _templates = {
  386 + # -- question templates --
376 'radio': 'question-radio.html', 387 'radio': 'question-radio.html',
377 'checkbox': 'question-checkbox.html', 388 'checkbox': 'question-checkbox.html',
378 'text': 'question-text.html', 389 'text': 'question-text.html',
@@ -396,6 +407,7 @@ class TestHandler(BaseHandler): @@ -396,6 +407,7 @@ class TestHandler(BaseHandler):
396 test = self.testapp.get_student_test(uid) # reloading returns same test 407 test = self.testapp.get_student_test(uid) # reloading returns same test
397 if test is None: 408 if test is None:
398 test = await self.testapp.generate_test(uid) 409 test = await self.testapp.generate_test(uid)
  410 +
399 self.render('test.html', t=test, md=md_to_html, templ=self._templates) 411 self.render('test.html', t=test, md=md_to_html, templ=self._templates)
400 412
401 # --- POST 413 # --- POST
@@ -438,20 +450,8 @@ class TestHandler(BaseHandler): @@ -438,20 +450,8 @@ class TestHandler(BaseHandler):
438 self.render('grade.html', t=test, allgrades=allgrades) 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 # --- REVIEW ----------------------------------------------------------------- 453 # --- REVIEW -----------------------------------------------------------------
  454 +# pylint: disable=abstract-method
455 class ReviewHandler(BaseHandler): 455 class ReviewHandler(BaseHandler):
456 ''' 456 '''
457 Show test for review 457 Show test for review
@@ -488,18 +488,20 @@ class ReviewHandler(BaseHandler): @@ -488,18 +488,20 @@ class ReviewHandler(BaseHandler):
488 with open(path.expanduser(fname)) as jsonfile: 488 with open(path.expanduser(fname)) as jsonfile:
489 test = json.load(jsonfile) 489 test = json.load(jsonfile)
490 except OSError: 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 except json.JSONDecodeError as exc: 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 self.render('review.html', t=test, md=md_to_html, 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 Catches Ctrl-C and stops webserver 506 Catches Ctrl-C and stops webserver
505 ''' 507 '''
perguntations/static/js/admin.js
@@ -119,7 +119,7 @@ $(document).ready(function() { @@ -119,7 +119,7 @@ $(document).ready(function() {
119 var checked = d['allowed'] ? 'checked' : ''; 119 var checked = d['allowed'] ? 'checked' : '';
120 var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : ''; 120 var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : '';
121 var hora_inicio = d['start_time'] ? ' <span class="badge badge-success"><i class="fas fa-hourglass-start"></i> ' + d['start_time'].slice(11,16) + '</span>': ''; 121 var hora_inicio = d['start_time'] ? ' <span class="badge badge-success"><i class="fas fa-hourglass-start"></i> ' + d['start_time'].slice(11,16) + '</span>': '';
122 - var unfocus = d['unfocus']? ' <span class="badge badge-danger">unfocus</span>' : ''; 122 + var unfocus = d['unfocus'] ? ' <span class="badge badge-danger">unfocus</span>' : '';
123 var area = ''; 123 var area = '';
124 if (d['start_time'] ) { 124 if (d['start_time'] ) {
125 if (d['area'] > 75) 125 if (d['area'] > 75)
perguntations/templates/test.html
@@ -44,7 +44,7 @@ @@ -44,7 +44,7 @@
44 <!-- ===================================================================== --> 44 <!-- ===================================================================== -->
45 <body> 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 <div class="progress-bar bg-secondary" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div> 48 <div class="progress-bar bg-secondary" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div>
49 </div> 49 </div>
50 50
perguntations/test.py
@@ -111,9 +111,9 @@ class TestFactory(dict): @@ -111,9 +111,9 @@ class TestFactory(dict):
111 # check if all the questions can be correctly generated 111 # check if all the questions can be correctly generated
112 try: 112 try:
113 self.question_factory[question['ref']].generate() 113 self.question_factory[question['ref']].generate()
114 - except Exception: 114 + except Exception as exc:
115 msg = f'Failed to generate "{question["ref"]}"' 115 msg = f'Failed to generate "{question["ref"]}"'
116 - raise TestFactoryException(msg) 116 + raise TestFactoryException(msg) from exc
117 else: 117 else:
118 logger.info('%4d. "%s" Ok.', counter, question["ref"]) 118 logger.info('%4d. "%s" Ok.', counter, question["ref"])
119 counter += 1 119 counter += 1
@@ -152,9 +152,9 @@ class TestFactory(dict): @@ -152,9 +152,9 @@ class TestFactory(dict):
152 try: 152 try:
153 with open(testfile, 'w') as file: 153 with open(testfile, 'w') as file:
154 file.write('You can safely remove this file.') 154 file.write('You can safely remove this file.')
155 - except OSError: 155 + except OSError as exc:
156 msg = f'Cannot write answers to directory "{self["answers_dir"]}"' 156 msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
157 - raise TestFactoryException(msg) 157 + raise TestFactoryException(msg) from exc
158 158
159 def check_questions_directory(self): 159 def check_questions_directory(self):
160 '''Check if questions directory is missing or not accessible.''' 160 '''Check if questions directory is missing or not accessible.'''
@@ -223,16 +223,16 @@ class TestFactory(dict): @@ -223,16 +223,16 @@ class TestFactory(dict):
223 self.check_grade_scaling() 223 self.check_grade_scaling()
224 224
225 # ------------------------------------------------------------------------ 225 # ------------------------------------------------------------------------
226 - async def generate(self, student): 226 + async def generate(self): #, student):
227 ''' 227 '''
228 Given a dictionary with a student dict {'name':'john', 'number': 123} 228 Given a dictionary with a student dict {'name':'john', 'number': 123}
229 returns instance of Test() for that particular student 229 returns instance of Test() for that particular student
230 ''' 230 '''
231 231
232 # make list of questions 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 for qlist in self['questions']: 237 for qlist in self['questions']:
238 # choose one question variant 238 # choose one question variant
@@ -255,28 +255,28 @@ class TestFactory(dict): @@ -255,28 +255,28 @@ class TestFactory(dict):
255 question['number'] = qnum # counter for non informative panels 255 question['number'] = qnum # counter for non informative panels
256 qnum += 1 256 qnum += 1
257 257
258 - test.append(question) 258 + questions.append(question)
259 259
260 # setup scale 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 if total_points > 0: 263 if total_points > 0:
264 # normalize question points to scale 264 # normalize question points to scale
265 if self['scale'] is not None: 265 if self['scale'] is not None:
266 scale_min, scale_max = self['scale'] 266 scale_min, scale_max = self['scale']
267 - for question in test: 267 + for question in questions:
268 question['points'] *= (scale_max - scale_min) / total_points 268 question['points'] *= (scale_max - scale_min) / total_points
269 else: 269 else:
270 self['scale'] = [0, total_points] 270 self['scale'] = [0, total_points]
271 else: 271 else:
272 logger.warning('Total points is **ZERO**.') 272 logger.warning('Total points is **ZERO**.')
273 if self['scale'] is None: 273 if self['scale'] is None:
274 - self['scale'] = [0, 20] 274 + self['scale'] = [0, 20] # default
275 275
276 if nerr > 0: 276 if nerr > 0:
277 logger.error('%s errors found!', nerr) 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 inherit = {'ref', 'title', 'database', 'answers_dir', 280 inherit = {'ref', 'title', 'database', 'answers_dir',
281 'questions_dir', 'files', 281 'questions_dir', 'files',
282 'duration', 'autosubmit', 282 'duration', 'autosubmit',
@@ -284,9 +284,7 @@ class TestFactory(dict): @@ -284,9 +284,7 @@ class TestFactory(dict):
284 'show_ref', 'debug', } 284 'show_ref', 'debug', }
285 # NOT INCLUDED: testfile, allow_all, review 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 def __repr__(self): 290 def __repr__(self):
@@ -301,8 +299,15 @@ class Test(dict): @@ -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 self['start_time'] = datetime.now() 311 self['start_time'] = datetime.now()
307 self['finish_time'] = None 312 self['finish_time'] = None
308 self['state'] = 'ACTIVE' 313 self['state'] = 'ACTIVE'
@@ -346,5 +351,12 @@ class Test(dict): @@ -346,5 +351,12 @@ class Test(dict):
346 self['finish_time'] = datetime.now() 351 self['finish_time'] = datetime.now()
347 self['state'] = 'QUIT' 352 self['state'] = 'QUIT'
348 self['grade'] = 0.0 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 return self['grade'] 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')