Commit a80f0415f31d36abac43208a3474dd6227d3e9b7

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

Major changes:

- fix and improve sanity checks
- show discount points
- add scale_min
- add support for list of regular expressions in regex questions
- improve documentation
- improves exception handling in python
- grades are no longer rounded in the database, but show rounded to 1
  decimal place in the browser.
BUGS.md
1 1  
2 2 # BUGS
3 3  
  4 +- na pagina grade.html as barras estao normalizadas para os limites scale_min e max do testte actual e nao do realizado.
4 5 - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>.
5   -- teste nao esta a mostrar imagens.
6   -- se houver erros a abrir ficheiros .yaml de perguntas, depois dos testes diz "No errors found".
7   -- dizer quanto desconta em cada pergunta de escolha multipla
  6 +- teste nao esta a mostrar imagens de vez em quando.
8 7 - ordenacao das notas em /admin nao é numerica, é ascii...
9 8 - mensagems de erro do assembler aparecem na mesma linha na correcao e nao fazerm rendering do `$t`, ver se servidor faz parse do markdown dessas mensagens.
10 9 - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente?
... ... @@ -16,10 +15,11 @@ ou usar push (websockets?)
16 15 - servidor nao esta a lidar com eventos scroll/resize. ignorar?
17 16 - Test.reset_answers() unused.
18 17 - mudar ref do test para test_id (ref já é usado nas perguntas)
  18 +- incluir test_id na tabela questions (futuro semestre, pode quebrar compatibilidade).
19 19  
20 20 # TODO
21 21  
22   -- nao esta a usar points das perguntas
  22 +- testar as perguntas todas no início do teste.
23 23 - test: mostrar duração do teste com progressbar no navbar.
24 24 - submissao fazer um post ajax?
25 25 - adicionar opcao para eliminar um teste em curso.
... ... @@ -59,6 +59,10 @@ ou usar push (websockets?)
59 59  
60 60 # FIXED
61 61  
  62 +- dizer quanto desconta em cada pergunta de escolha multipla
  63 +- se houver erros a abrir ficheiros .yaml de perguntas, depois dos testes diz "No errors found".
  64 +- se faltarem files na especificação do teste, o check não detecta e factory não gera para essas perguntas.
  65 +- nao esta a usar points das perguntas
62 66 - quando se clica no texto de uma opcao, salta para outro lado na pagina.
63 67 - suportar cotacao to teste diferente de 20 (e.g. para juntar perguntas em papel). opcao "points: 18" que normaliza total para 18 em vez de 20.
64 68 - fazer package para instalar perguntations com pip.
... ...
README.md
1 1 # User Manual
2 2  
3 3 1. [Requirements](#requirements)
4   -2. [Installation](#installation)
5   -3. [Setup](#setup)
6   -4. [Running a demo](#running-a-demo)
7   -5. [Running on lower ports](#running-on-lower-ports)
8   -6. [Troubleshooting](#troubleshooting)
  4 +1. [Installation](#installation)
  5 +1. [Setup](#setup)
  6 +1. [Running a demo](#running-a-demo)
  7 +1. [Running on lower ports](#running-on-lower-ports)
  8 +1. [Troubleshooting](#troubleshooting)
9 9  
10 10 ---
11 11  
... ... @@ -260,7 +260,7 @@ Solutions:
260 260  
261 261 ---
262 262  
263   -## Contribute
  263 +## Please contribute
264 264  
265 265 - Writing questions in yaml format
266 266 - Testing and reporting bugs
... ...
demo/demo.yaml
1 1 ---
2 2 # ============================================================================
3 3 # The test reference should be a unique identifier. It is saved in the database
4   -# so that queries for the results can be done in the terminal with
5   -# $ sqlite3 students.db "select * from tests where ref='demo'"
  4 +# so that queries can be done in the terminal like
  5 +# sqlite3 students.db "select * from tests where ref='demo'"
6 6 ref: tutorial
7 7  
8   -# (optional, default: '') You may wish to refer the course, year or kind of test
9   -title: Teste de demonstração (tutorial)
10   -
11   -# (optional) duration in minutes, 0 or undefined is infinite
12   -duration: 60
13   -
14 8 # Database with student credentials and grades of all questions and tests done
15   -# The database is an sqlite3 file generate with the script initdb.py
16   -database: students.db
  9 +# The database is an sqlite3 file generate with the command initdb
  10 +database: ../demo/students.db
17 11  
18   -# Generate a file for each test done by a student.
19   -# It includes the questions, answers and grades.
  12 +# Directory where the tests including submitted answers and grades are stored.
  13 +# The submitted tests and their corrections can be reviewed later.
20 14 answers_dir: ans
21 15  
22   -# (optional, default: False) Show points for each question, scale 0-20.
  16 +# --- optional settings: -----------------------------------------------------
  17 +
  18 +# You may wish to refer the course, year or kind of test
  19 +# (default: '')
  20 +title: Teste de demonstração (tutorial)
  21 +
  22 +# Duration in minutes.
  23 +# (0 or undefined means infinite time)
  24 +duration: 60
  25 +
  26 +# Show points for each question, scale 0-20.
  27 +# (default: false)
23 28 show_points: true
24   -# scale_points: true
25   -# scale_max: 20
  29 +
  30 +# scale final grade to the interval [scale_min, scale_max]
  31 +# (default: scale to [0,20])
  32 +scale_points: true
  33 +scale_max: 20
  34 +scale_min: 0
26 35  
27 36 # ----------------------------------------------------------------------------
28 37 # Base path applied to the questions files and all the scripts
... ...
demo/questions/questions-tutorial.yaml
... ... @@ -17,15 +17,16 @@
17 17 # --------------------------------------------------------------------------
18 18 ref: tutorial # referência, pode ser reusada em vários turnos
19 19 title: Demonstração # título da prova
20   - database: students.db # base de dados já inicializada com initdb
21   - answers_dir: ans # directório onde ficam os testes entregues
  20 + database: students.db # base de dados previamente criada com initdb
  21 + answers_dir: ans # directório onde ficam os testes dos alunos
22 22  
23 23 # opcional
24   - duration: 60 # duração da prova em minutos (default=inf)
25   - show_points: true # mostra cotação das perguntas
26   - scale_points: true # recalcula cotações para a escala [0, scale_max]
27   - scale_max: 20 # limite superior da escala
28   - debug: false # mostra informação de debug na prova (browser)
  24 + duration: 60 # duração da prova em minutos (default: inf)
  25 + show_points: true # mostra cotação das perguntas (default: true)
  26 + scale_points: true # recalcula cotações para [scale_min, scale_max]
  27 + scale_max: 20 # limite superior da escala (default: 20)
  28 + scale_min: 0 # limite inferior da escala (default: 0)
  29 + debug: false # mostra informação de debug no browser
29 30  
30 31 # --------------------------------------------------------------------------
31 32 questions_dir: ~/topics # raíz da árvore de directórios das perguntas
... ... @@ -46,25 +47,24 @@
46 47 points: 3.5
47 48  
48 49 - ref: pergunta2
49   - points: 2
  50 + point: 2.0
50 51  
51   - - ref: tabela-auxiliar
  52 + # a cotação é 1.0 por defeito, se omitida
  53 + - ref: pergunta3
  54 +
  55 + # uma string (não dict), é interpretada como referência
  56 + - tabela-auxiliar
52 57  
53 58 # escolhe aleatoriamente uma das variantes
54 59 - ref: [pergunta3a, pergunta3b]
55 60 points: 0.5
56 61  
57   - # a cotação é 1.0 por defeito (se omitida)
58   - - ref: pergunta4
59   -
60   - # se for uma string (não dict), é interpretada como referência
61   - - pergunta5
62 62 # --------------------------------------------------------------------------
63 63 ```
64 64  
65   - A ordem das perguntas é mantida quando apresentada para o aluno.
  65 + A ordem das perguntas é mantida quando apresentada no teste.
66 66  
67   - O mesmo teste pode ser realizado várias vezes em vários turnos, não é
  67 + O mesmo teste pode ser realizado várias vezes em turnos diferentes, não é
68 68 necessário alterar nada.
69 69  
70 70 # ----------------------------------------------------------------------------
... ... @@ -73,14 +73,14 @@
73 73 title: Especificação das perguntas
74 74 text: |
75 75 As perguntas estão definidas num ou mais ficheiros `yaml` como uma lista de
76   - dicionários, onde cada pergunta é um dicionário.
  76 + perguntas, onde cada pergunta é um dicionário.
77 77  
78 78 Por exemplo, um ficheiro com o conteúdo abaixo contém duas perguntas, uma
79 79 de escolha múltipla e outra apenas informativa:
80 80  
81 81 ```yaml
82 82 ---
83   - #-----------------------------------------------------------------------------
  83 + #---------------------------------------------------------------------------
84 84 - type: radio
85 85 ref: chave-unica-1
86 86 text: Quanto é $1+1$?
... ... @@ -89,7 +89,7 @@
89 89 - 2
90 90 - 3
91 91  
92   - #-----------------------------------------------------------------------------
  92 + #---------------------------------------------------------------------------
93 93 - type: information
94 94 ref: chave-unica-2
95 95 text: |
... ... @@ -98,13 +98,15 @@
98 98 texto.
99 99 É o caso desta pergunta.
100 100  
101   - O texto das perguntas é escrito em `markdown`.
  101 + O texto das perguntas é escrito em `markdown` e suporta fórmulas em
  102 + LaTeX.
102 103  
103   - #-----------------------------------------------------------------------------
  104 + #---------------------------------------------------------------------------
104 105 ```
105 106  
106   - As chaves são usadas para construir o teste e não se podem repetir em
107   - ficheiros diferentes. A seguir vamos ver exemplos de cada tipo de pergunta.
  107 + As chaves são usadas para construir o teste e não se podem repetir, mesmo em
  108 + ficheiros diferentes.
  109 + De seguida mostram-se exemplos dos vários tipos de perguntas.
108 110  
109 111 # ----------------------------------------------------------------------------
110 112 - type: radio
... ... @@ -144,13 +146,14 @@
144 146 choose: 3
145 147 ```
146 148  
147   - Neste caso, será escolhida uma opção certa de entre o conjunto das certas
148   - e duas erradas de entre o conjunto das erradas.
  149 + Neste caso, será escolhida uma opção certa e duas erradas.
149 150 Os valores em `correct` representam o grau de correcção no intervalo [0, 1]
150   - onde 1 representa 100% certo e 0 representa 0%.
  151 + onde 1 representa 100% certo e 0 representa 0%. Podem ser usados valores
  152 + entre 0 e 1, sendo atribuída a respectiva cotação, mas só o valor 1
  153 + representa uma opção certa.
151 154  
152 155 Por defeito, as opções são apresentadas por ordem aleatória.
153   - Para manter a ordem acrescenta-se:
  156 + Para manter a ordem definida acrescenta-se:
154 157  
155 158 ```yaml
156 159 shuffle: false
... ... @@ -200,7 +203,7 @@
200 203 - Opção 2
201 204 - Opção 3 (certa)
202 205 - Opção 4
203   - correct: [1, -1, -1, 1, -1]
  206 + correct: [+1, -1, -1, +1, -1]
204 207 ```
205 208  
206 209 Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada
... ... @@ -209,10 +212,10 @@
209 212 Por exemplo se não seleccionar a opção 0, tem cotação -1, e não
210 213 seleccionando a opção 1 obtém-se +1.
211 214  
212   - *(Note que o `correct` não funciona do mesmo modo que nas perguntas do
213   - tipo `radio`. Em geral, um aluno só deve responder se souber mais de metade
214   - das respostas, caso contrário arrisca-se a ter cotação negativa na
215   - pergunta. Não há forma de não responder a apenas algumas delas.)*
  215 + *(Neste tipo de perguntas não há forma de responder a apenas algumas delas,
  216 + são sempre todas corrigidas. Se um aluno só sabe a resposta a algumas das
  217 + opções, deve ter cuidado porque as restantes também serão classificadas e
  218 + arrisca-se a ter cotação negativa)*
216 219  
217 220 Cada opção pode opcionalmente ser escrita como uma afirmação e o seu
218 221 contrário, de maneira a aumentar a variabilidade dos textos.
... ... @@ -282,6 +285,16 @@
282 285  
283 286 Neste exemplo a expressão regular é `(VERDE|[Vv]erde)`.
284 287  
  288 + Também se pode dar uma lista de expressões regulares. Nesse caso a resposta
  289 + é considerada correcta se fizer match com alguma delas.
  290 + No exemplo acima, poder-se-ia ter usado uma lista
  291 +
  292 + ```yaml
  293 + correct:
  294 + - 'VERDE'
  295 + - '[Vv]erde'
  296 + ```
  297 +
285 298 ---
286 299  
287 300 **Atenção:** A expressão regular deve seguir as convenções da suportadas em
... ... @@ -289,7 +302,8 @@
289 302 [Regular expression operations](https://docs.python.org/3/library/re.html)).
290 303 Em particular, a expressão regular acima também aceita a resposta
291 304 `verde, azul`.
292   - Possivelmente devia marcar-se o final com o cifrão `(VERDE|[Vv]erde)$`.
  305 + Deve marcar-se o início e final `^(VERDE|[Vv]erde)$` para evitar estas
  306 + situações.
293 307  
294 308 correct: '(VERDE|[Vv]erde)'
295 309  
... ... @@ -311,7 +325,8 @@
311 325 correct: [3.14, 3.15]
312 326 ```
313 327  
314   - Neste exemplo o intervalo de respostas correctas é [3.14, 3.15].
  328 + Neste exemplo o intervalo de respostas correctas é o intervalo fechado
  329 + [3.14, 3.15].
315 330  
316 331 **Atenção:** as respostas têm de usar o ponto como separador decimal.
317 332 Em geral são aceites números inteiros, como `123`,
... ... @@ -349,9 +364,8 @@
349 364 Neste exemplo, o programa de avaliação é um script python que verifica se a
350 365 resposta contém as três palavras red, green e blue, e calcula uma nota no
351 366 intervalo 0.0 a 1.0.
352   - O programa externo é executado num processo separado do sistema operativo.
353   - Pode escrito em qualquer linguagem de programação, desde que . A interacção com o servidor faz-se
354   - sempre via stdin/stdout.
  367 + O programa externo é executado num processo separado e a interacção faz-se
  368 + via stdin/stdout.
355 369  
356 370 Se o programa externo exceder o `timeout` indicado (em segundos),
357 371 este é automaticamente terminado e é atribuída a classificação de 0.0
... ... @@ -424,9 +438,11 @@
424 438 ref: tut-success
425 439 title: Texto informativo (sucesso)
426 440 text: |
427   - Também não conta para avaliação. É apenas o aspecto gráfico que muda.
  441 + Não conta para avaliação. É apenas o aspecto gráfico que muda.
428 442  
429   - Além das fórmulas LaTeX, também se podem escrever troços de código:
  443 + Um pedaço de código em linha, por exemplo `x = sqrt(z)` é marcado com uma
  444 + fonte e cor diferente.
  445 + Também se podem escrever troços de código coloridos conforme a linguagem:
430 446  
431 447 ```C
432 448 int main() {
... ... @@ -437,21 +453,25 @@
437 453  
438 454 ---
439 455  
440   - - type: success
441   - ref: tut-success
442   - title: Texto informativo (sucesso)
443   - text: |
444   - Também não conta para avaliação.
445   - É apenas o aspecto gráfico que muda.
446   -
447   - Além das fórmulas LaTeX, também se pode escrever troços de código:
448   -
449   - ```C
450   - int main() {
451   - printf("Hello world!");
452   - return 0; // comentario
453   - }
454   - ```
  456 + ```yaml
  457 + - type: success
  458 + ref: tut-success
  459 + title: Texto informativo (sucesso)
  460 + text: |
  461 + Não conta para avaliação. É apenas o aspecto gráfico que muda.
  462 +
  463 + Um pedaço de código em linha, por exemplo `x = sqrt(z)` é marcado com
  464 + uma fonte e cor diferente.
  465 + Também se podem escrever troços de código coloridos conforme a
  466 + linguagem:
  467 +
  468 + ```C
  469 + int main() {
  470 + printf("Hello world!");
  471 + return 0; // comentario
  472 + }
  473 + ```
  474 + ```
455 475  
456 476 # ---------------------------------------------------------------------------
457 477 - type: warning
... ... @@ -468,7 +488,8 @@
468 488 **world** | $\frac{1}{2\pi}$ | $12.50
469 489 `code` | $\sqrt{\pi}$ | $1.99
470 490  
471   - As tabelas podem conter Markdown e LaTeX.
  491 + As tabelas podem conter Markdown e LaTeX e permitem alinhamento das colunas,
  492 + mas o markdown é muito simples e não permite mais funcionalidades.
472 493  
473 494 ---
474 495  
... ... @@ -505,6 +526,11 @@
505 526  
506 527 # ----------------------------------------------------------------------------
507 528 - type: information
508   - text: This question is not included in the test and will not show up.
  529 + text: |
  530 + This question is not included in the test and will not shown up.
  531 + It also lacks a "ref" and is automatically named
  532 + `questions/questions-tutorial.yaml:0012`.
  533 + The number at the end is the index position of this question.
  534 + Indices start at 0.
509 535  
510 536 # ----------------------------------------------------------------------------
... ...
package-lock.json
... ... @@ -13,9 +13,9 @@
13 13 "integrity": "sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag=="
14 14 },
15 15 "codemirror": {
16   - "version": "5.49.0",
17   - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.0.tgz",
18   - "integrity": "sha512-Hyzr0HToBdZpLBN9dYFO/KlJAsKH37/cXVHPAqa+imml0R92tb9AkmsvjnXL+SluEvjjdfkDgRjc65NG5jnMYA=="
  16 + "version": "5.49.2",
  17 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.2.tgz",
  18 + "integrity": "sha512-dwJ2HRPHm8w51WB5YTF9J7m6Z5dtkqbU9ntMZ1dqXyFB9IpjoUFDj80ahRVEoVanfIp6pfASJbOlbWdEf8FOzQ=="
19 19 },
20 20 "commander": {
21 21 "version": "3.0.1",
... ...
package.json
... ... @@ -4,7 +4,7 @@
4 4 "dependencies": {
5 5 "@fortawesome/fontawesome-free": "^5.11.2",
6 6 "bootstrap": "^4.3",
7   - "codemirror": "^5.49.0",
  7 + "codemirror": "^5.49.2",
8 8 "datatables": "^1.10",
9 9 "jquery": "^3.4.1",
10 10 "mathjax": "^3",
... ...
perguntations/app.py
... ... @@ -62,21 +62,21 @@ class App(object):
62 62  
63 63 # ------------------------------------------------------------------------
64 64 def __init__(self, conf={}):
65   - logger.info('Starting application.')
66 65 self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...}
67 66 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere
68 67  
69 68 logger.info(f'Loading test configuration "{conf["filename"]}".')
70   - testconf = load_yaml(conf['filename'], default={})
71   - testconf.update(conf) # configuration overrides from command line
  69 + testconf = load_yaml(conf['filename'])
  70 + testconf.update(conf) # command line options override configuration
72 71  
73 72 # start test factory
74   - logger.info(f'Creating test factory.')
75 73 try:
76 74 self.testfactory = TestFactory(testconf)
77   - except TestFactoryException:
78   - logger.critical('Cannot create test factory.')
79   - raise AppException()
  75 + except TestFactoryException as e:
  76 + logger.critical(e)
  77 + raise AppException('Failed to create test factory!')
  78 + else:
  79 + logger.info('No errors found. Test factory ready.')
80 80  
81 81 # connect to database and check registered students
82 82 dbfile = self.testfactory['database']
... ... @@ -87,8 +87,7 @@ class App(object):
87 87 with self.db_session() as s:
88 88 n = s.query(Student).filter(Student.id != '0').count()
89 89 except Exception:
90   - logger.critical(f'Database unusable {dbfile}.')
91   - raise AppException()
  90 + raise AppException(f'Database unusable {dbfile}.')
92 91 else:
93 92 logger.info(f'Database {dbfile} has {n} students.')
94 93  
... ... @@ -99,11 +98,12 @@ class App(object):
99 98 self.allow_student(student[0])
100 99  
101 100 # ------------------------------------------------------------------------
102   - def exit(self):
103   - if len(self.online) > 1:
104   - online_students = ', '.join(self.online)
105   - logger.warning(f'Students still online: {online_students}')
106   - logger.critical('----------- !!! Server terminated !!! -----------')
  101 + # FIXME unused???
  102 + # def exit(self):
  103 + # if len(self.online) > 1:
  104 + # online_students = ', '.join(self.online)
  105 + # logger.warning(f'Students still online: {online_students}')
  106 + # logger.critical('----------- !!! Server terminated !!! -----------')
107 107  
108 108 # ------------------------------------------------------------------------
109 109 async def login(self, uid, try_pw):
... ... @@ -165,7 +165,7 @@ class App(object):
165 165 logger.info(f'Student {uid}: {len(ans)} answers submitted.')
166 166  
167 167 grade = await t.correct()
168   - logger.info(f'Student {uid}: grade = {grade} points.')
  168 + logger.info(f'Student {uid}: grade = {grade:5.3} points.')
169 169  
170 170 # --- save test in JSON format
171 171 fields = (uid, t['ref'], str(t['finish_time']))
... ...
perguntations/questions.py
1 1  
2 2 # python standard library
  3 +import asyncio
3 4 import random
4 5 import re
5 6 from os import path
... ... @@ -21,10 +22,10 @@ class QuestionException(Exception):
21 22 pass
22 23  
23 24  
24   -# ===========================================================================
  25 +# ============================================================================
25 26 # Questions derived from Question are already instantiated and ready to be
26 27 # presented to students.
27   -# ===========================================================================
  28 +# ============================================================================
28 29 class Question(dict):
29 30 '''
30 31 Classes derived from this base class are meant to instantiate questions
... ... @@ -41,7 +42,7 @@ class Question(dict):
41 42 'comments': '',
42 43 'solution': '',
43 44 'files': {},
44   - 'max_tries': 3,
  45 + # 'max_tries': 3,
45 46 }))
46 47  
47 48 def correct(self) -> None:
... ... @@ -57,7 +58,7 @@ class Question(dict):
57 58 self.setdefault(k, v)
58 59  
59 60  
60   -# ==========================================================================
  61 +# ============================================================================
61 62 class QuestionRadio(Question):
62 63 '''An instance of QuestionRadio will always have the keys:
63 64 type (str)
... ... @@ -92,7 +93,7 @@ class QuestionRadio(Question):
92 93 for x in range(n)]
93 94  
94 95 if len(self['correct']) != n:
95   - msg = (f'Options and correct mismatch in '
  96 + msg = ('Number of options and correct differ in '
96 97 f'"{self["ref"]}", file "{self["filename"]}".')
97 98 logger.error(msg)
98 99 raise QuestionException(msg)
... ... @@ -138,7 +139,7 @@ class QuestionRadio(Question):
138 139 self['grade'] = x
139 140  
140 141  
141   -# ===========================================================================
  142 +# ============================================================================
142 143 class QuestionCheckbox(Question):
143 144 '''An instance of QuestionCheckbox will always have the keys:
144 145 type (str)
... ... @@ -218,7 +219,7 @@ class QuestionCheckbox(Question):
218 219 self['grade'] = x / sum_abs
219 220  
220 221  
221   -# ===========================================================================
  222 +# ============================================================================
222 223 class QuestionText(Question):
223 224 '''An instance of QuestionText will always have the keys:
224 225 type (str)
... ... @@ -251,13 +252,16 @@ class QuestionText(Question):
251 252 self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0
252 253  
253 254  
254   -# ===========================================================================
  255 +# ============================================================================
255 256 class QuestionTextRegex(Question):
256 257 '''An instance of QuestionTextRegex will always have the keys:
257 258 type (str)
258 259 text (str)
259   - correct (str with regex)
  260 + correct (str or list[str])
260 261 answer (None or an actual answer)
  262 +
  263 + The correct strings are python standard regular expressions.
  264 + Grade is 1.0 when the answer matches any of the regex in the list.
261 265 '''
262 266  
263 267 # ------------------------------------------------------------------------
... ... @@ -266,22 +270,32 @@ class QuestionTextRegex(Question):
266 270  
267 271 self.set_defaults(QDict({
268 272 'text': '',
269   - 'correct': '$.^', # will always return false
  273 + 'correct': ['$.^'], # will always return false
270 274 }))
271 275  
  276 + # make sure its always a list of regular expressions
  277 + if not isinstance(self['correct'], list):
  278 + self['correct'] = [self['correct']]
  279 +
  280 + # make sure all elements of the list are strings
  281 + self['correct'] = [str(a) for a in self['correct']]
  282 +
272 283 # ------------------------------------------------------------------------
273 284 def correct(self) -> None:
274 285 super().correct()
275 286 if self['answer'] is not None:
276   - try:
277   - ok = re.match(self['correct'], self['answer'])
278   - except TypeError:
279   - logger.error(f'While matching regex {self["correct"]} with '
280   - f'answer {self["answer"]}.')
281   - self['grade'] = 1.0 if ok else 0.0
  287 + self['grade'] = 0.0
  288 + for r in self['correct']:
  289 + try:
  290 + if re.match(r, self['answer']):
  291 + self['grade'] = 1.0
  292 + return
  293 + except TypeError:
  294 + logger.error(f'While matching regex {self["correct"]} with'
  295 + f' answer "{self["answer"]}".')
282 296  
283 297  
284   -# ===========================================================================
  298 +# ============================================================================
285 299 class QuestionNumericInterval(Question):
286 300 '''An instance of QuestionTextNumeric will always have the keys:
287 301 type (str)
... ... @@ -316,7 +330,7 @@ class QuestionNumericInterval(Question):
316 330 self['grade'] = 1.0 if lower <= answer <= upper else 0.0
317 331  
318 332  
319   -# ===========================================================================
  333 +# ============================================================================
320 334 class QuestionTextArea(Question):
321 335 '''An instance of QuestionTextArea will always have the keys:
322 336 type (str)
... ... @@ -397,7 +411,7 @@ class QuestionTextArea(Question):
397 411 logger.error(f'Invalid grade in "{self["correct"]}".')
398 412  
399 413  
400   -# ===========================================================================
  414 +# ============================================================================
401 415 class QuestionInformation(Question):
402 416 # ------------------------------------------------------------------------
403 417 def __init__(self, q: QDict) -> None:
... ... @@ -412,30 +426,38 @@ class QuestionInformation(Question):
412 426 self['grade'] = 1.0 # always "correct" but points should be zero!
413 427  
414 428  
415   -# ===========================================================================
  429 +# ============================================================================
  430 +#
416 431 # QFactory is a class that can generate question instances, e.g. by shuffling
417 432 # options, running a script to generate the question, etc.
418 433 #
419   -# To generate an instance of a question we use the method generate() where
420   -# the argument is the reference of the question we wish to produce.
421   -# The generate() method returns a question instance of the correct class.
  434 +# To generate an instance of a question we use the method generate().
  435 +# It returns a question instance of the correct class.
  436 +# There is also an asynchronous version called gen_async(). This version is
  437 +# synchronous for all question types (radio, checkbox, etc) except for
  438 +# generator types which run asynchronously.
422 439 #
423 440 # Example:
424 441 #
425   -# # generate a question instance from a dictionary
426   -# qdict = {
  442 +# # make a factory for a question
  443 +# qfactory = QFactory({
427 444 # 'type': 'radio',
428 445 # 'text': 'Choose one',
429 446 # 'options': ['a', 'b']
430   -# }
431   -# qfactory = QFactory(qdict)
  447 +# })
  448 +#
  449 +# # generate synchronously
432 450 # question = qfactory.generate()
433 451 #
  452 +# # generate asynchronously
  453 +# question = await qfactory.gen_async()
  454 +#
434 455 # # answer one question and correct it
435 456 # question['answer'] = 42 # set answer
436 457 # question.correct() # correct answer
437 458 # grade = question['grade'] # get grade
438   -# ===========================================================================
  459 +#
  460 +# ============================================================================
439 461 class QFactory(object):
440 462 # Depending on the type of question, a different question class will be
441 463 # instantiated. All these classes derive from the base class `Question`.
... ... @@ -456,43 +478,12 @@ class QFactory(object):
456 478 def __init__(self, qdict: QDict = QDict({})) -> None:
457 479 self.question = qdict
458 480  
459   - # -----------------------------------------------------------------------
460   - # Given a ref returns an instance of a descendent of Question(),
461   - # i.e. a question object (radio, checkbox, ...).
462   - # -----------------------------------------------------------------------
463   - def generate(self) -> Question:
464   - logger.debug(f'[QFactory.generate] "{self.question["ref"]}"...')
465   - # Shallow copy so that script generated questions will not replace
466   - # the original generators
467   - q = self.question.copy()
468   - q['qid'] = str(uuid.uuid4()) # unique for each generated question
469   -
470   - # If question is of generator type, an external program will be run
471   - # which will print a valid question in yaml format to stdout. This
472   - # output is then yaml parsed into a dictionary `q`.
473   - if q['type'] == 'generator':
474   - logger.debug(f' \\_ Running "{q["script"]}".')
475   - q.setdefault('args', [])
476   - q.setdefault('stdin', '') # FIXME is it really necessary?
477   - script = path.join(q['path'], q['script'])
478   - out = run_script(script=script, args=q['args'], stdin=q['stdin'])
479   - q.update(out)
480   -
481   - # Finally we create an instance of Question()
482   - try:
483   - qinstance = self._types[q['type']](QDict(q)) # of matching class
484   - except QuestionException as e:
485   - logger.error(e)
486   - raise e
487   - except KeyError:
488   - logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
489   - raise
490   - else:
491   - return qinstance
492   -
493   - # -----------------------------------------------------------------------
494   - async def generate_async(self) -> Question:
495   - logger.debug(f'[QFactory.generate_async] "{self.question["ref"]}"...')
  481 + # ------------------------------------------------------------------------
  482 + # generates a question instance of QuestionRadio, QuestionCheckbox, ...,
  483 + # which is a descendent of base class Question.
  484 + # ------------------------------------------------------------------------
  485 + async def gen_async(self) -> Question:
  486 + logger.debug(f'generating {self.question["ref"]}...')
496 487 # Shallow copy so that script generated questions will not replace
497 488 # the original generators
498 489 q = self.question.copy()
... ... @@ -520,5 +511,8 @@ class QFactory(object):
520 511 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
521 512 raise
522 513 else:
523   - logger.debug(f'[generate_async] Done instance of {q["ref"]}')
524 514 return qinstance
  515 +
  516 + # ------------------------------------------------------------------------
  517 + def generate(self) -> Question:
  518 + return asyncio.get_event_loop().run_until_complete(self.gen_async())
... ...
perguntations/serve.py
... ... @@ -490,7 +490,7 @@ def main():
490 490  
491 491 try:
492 492 testapp = App(config)
493   - except AppException:
  493 + except Exception:
494 494 logging.critical('Failed to start application.')
495 495 sys.exit(-1)
496 496  
... ...
perguntations/static/js/admin.js
... ... @@ -70,10 +70,10 @@ $(document).ready(function() {
70 70 else
71 71 barcolor = 'bg-success';
72 72  
73   - return '<div class="progress"><div class="progress-bar ' + barcolor
  73 + return '<div class="progress" style="height: 20px;"><div class="progress-bar ' + barcolor
74 74 + '" role="progressbar" aria-valuenow="' + grade
75 75 + '" aria-valuemin="0" aria-valuemax="20" style="min-width: 2em; width: '
76   - + (5*grade) + '%;">' + grade + '</div></div>';
  76 + + (5*grade) + '%;">' + Math.round(10*grade)/10 + '</div></div>';
77 77 }
78 78  
79 79 // ----------------------------------------------------------------------
... ...
perguntations/templates/grade.html
... ... @@ -39,69 +39,70 @@
39 39 </nav>
40 40 <!-- ================================================================== -->
41 41 <div class="container">
42   - <div class="jumbotron">
43   - <h1>Resultado</h1>
44   -
45   - {% if t['state'] == 'FINISHED' %}
46   - <p><strong>{{t['grade']}}</strong> valores na escala de 0 a 20.</p>
47   - {% if t['grade'] >= 15 %}
48   - <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i>
49   -
50   - {% end %}
51   - {% elif t['state'] == 'QUIT' %}
52   - <p>Foi registada a sua desistência da prova.</p>
53   -
  42 + <div class="jumbotron">
  43 + {% if t['state'] == 'FINISHED' %}
  44 + <h1>Resultado:
  45 + <strong>{{ f'{round(t["grade"], 1)}' }}</strong>
  46 + valores na escala de {{t['scale_min']}} a {{t['scale_max']}}.
  47 + </h1>
  48 + <p>O seu teste foi registado.<br>
  49 + Pode fechar o browser e desligar o computador.</p>
  50 + {% if t['grade'] - t['scale_min'] >= 0.75*(t['scale_max'] - t['scale_min']) %}
  51 + <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i>
54 52 {% end %}
  53 + {% elif t['state'] == 'QUIT' %}
  54 + <p>Foi registada a sua desistência da prova.</p>
  55 + {% end %}
  56 +
  57 + </div> <!-- jumbotron -->
55 58  
56   - <p>Pode fechar o browser e desligar o computador.</p>
57   - </div>
  59 + <div class="card">
  60 + <h5 class="card-header">
  61 + Histórico de resultados
  62 + </h5>
  63 + <table class="table table-condensed noleftmargin">
  64 + <thead>
  65 + <tr>
  66 + <th>Prova</th>
  67 + <th>Data</th>
  68 + <th>Hora</th>
  69 + <th>Nota</th>
  70 + </tr>
  71 + </thead>
  72 + <tbody>
  73 + {% for g in allgrades %}
  74 + <tr>
  75 + <td>{{g[0]}}</td> <!-- teste -->
  76 + <td>{{g[2][:10]}}</td> <!-- data -->
  77 + <td>{{g[2][11:19]}}</td> <!-- hora -->
  78 + <td> <!-- progress column -->
  79 + <div class="progress" style="height: 20px;">
  80 + <div class="progress-bar
  81 + {% if g[1] - t['scale_min'] < 0.5*(t['scale_max'] - t['scale_min']) %}
  82 + bg-danger
  83 + {% elif g[1] - t['scale_min'] < 0.75*(t['scale_max'] - t['scale_min']) %}
  84 + bg-warning
  85 + {% else %}
  86 + bg-success
  87 + {% end %}
  88 + "
  89 + role="progressbar"
  90 + aria-valuenow="{{ round(100*(g[1] - t['scale_min'])/(t['scale_max'] - t['scale_min'])) }}"
  91 + aria-valuemin="0"
  92 + aria-valuemax="100"
  93 + style="min-width: 2em; width: {{ round(100*(g[1]-t['scale_min'])/(t['scale_max']-t['scale_min'])) }}%;"
  94 + >
58 95  
59   - <div class="card">
60   - <h5 class="card-header">
61   - Histórico de resultados
62   - </h5>
63   - <!-- <div class="card-body"> -->
64   - <table class="table table-condensed noleftmargin">
65   - <thead>
66   - <tr>
67   - <th>Prova</th>
68   - <th>Data</th>
69   - <th>Hora</th>
70   - <th>Nota (0-20)</th>
71   - </tr>
72   - </thead>
73   - <tbody>
74   - {% for g in allgrades %}
75   - <tr>
76   - <td>{{g[0]}}</td> <!-- teste -->
77   - <td>{{g[2][:10]}}</td> <!-- data -->
78   - <td>{{g[2][11:19]}}</td> <!-- hora -->
79   - <td>
80   - <div class="progress">
81   - <div class="progress-bar
82   - {% if g[1] < 10 %}
83   - bg-danger
84   - {% elif g[1] < 15 %}
85   - bg-warning
86   - {% else %}
87   - bg-success
88   - {% end %}
89   - "
90   - role="progressbar"
91   - aria-valuenow="{{ round(g[1]) }}" aria-valuemin="0"
92   - aria-valuemax="20"
93   - style="min-width: 2em; width: {{ round(5 * g[1]) }}%;">
  96 + {{ str(round(g[1], 1)) }}
94 97  
95   - {{ '{:.1f}'.format(g[1]) }}
96   - </div>
97   - </div>
98   - </td>
99   - </tr>
100   - {% end %}
101   - </tbody>
102   - </table>
103   - <!-- </div> --> <!-- body -->
104   - </div> <!-- panel -->
  98 + </div> <!-- progress-bar -->
  99 + </div> <!-- progress -->
  100 + </td> <!-- progress column -->
  101 + </tr>
  102 + {% end %}
  103 + </tbody>
  104 + </table>
  105 + </div> <!-- panel -->
105 106 </div> <!-- container -->
106 107 </body>
107 108 </html>
... ...
perguntations/templates/question.html
... ... @@ -18,7 +18,13 @@
18 18  
19 19 <p class="text-right">
20 20 <small>
21   - (Cotação: {{ round(q['points'], 2) }} pontos)
  21 + {% if q['type'] == 'radio' %}
  22 + (Cotação: {{ -round(q['points']/(len(q['options'])-1), 2) }} a {{ round(q['points'], 2) }} pontos)
  23 + {% elif q['type'] == 'checkbox' %}
  24 + (Cotação: {{ -round(q['points'], 2) }} a {{ round(q['points'], 2) }} pontos)
  25 + {% else %}
  26 + (Cotação: 0 a {{ round(q['points'], 2) }} pontos)
  27 + {% end %}
22 28 </small>
23 29 </p>
24 30 </div>
... ...
perguntations/templates/review-question.html
... ... @@ -21,7 +21,13 @@
21 21  
22 22 <p class="text-right">
23 23 <small>
24   - (Cotação: {{ round(q['points'], 2) }})
  24 + {% if q['type'] == 'radio' %}
  25 + (Cotação: {{ -round(q['points']/(len(q['options'])-1), 2) }} a {{ round(q['points'], 2) }} pontos)
  26 + {% elif q['type'] == 'checkbox' %}
  27 + (Cotação: {{ -round(q['points'], 2) }} a {{ round(q['points'], 2) }} pontos)
  28 + {% else %}
  29 + (Cotação: 0 a {{ round(q['points'], 2) }} pontos)
  30 + {% end %}
25 31 </small>
26 32 </p>
27 33 </div> <!-- card-body -->
... ...
perguntations/templates/review.html
... ... @@ -95,7 +95,7 @@
95 95 <div class="row">
96 96 <label for="nota" class="col-sm-2">Nota:</label>
97 97 <div class="col-sm-10" id="nota">
98   - <span class="badge badge-primary">{{ t['grade'] }}</span> valores
  98 + <span class="badge badge-primary">{{ round(t['grade'], 1) }}</span> valores
99 99 {% if t['state'] == 'QUIT' %}
100 100 (DESISTÊNCIA)
101 101 {% end %}
... ...
perguntations/templates/test.html
... ... @@ -17,9 +17,7 @@
17 17 }
18 18 };
19 19 </script>
20   - <script type="text/javascript" id="MathJax-script" async
21   - src="/static/mathjax/es5/tex-svg.js">
22   - </script>
  20 + <script async type="text/javascript" id="MathJax-script" src="/static/mathjax/es5/tex-svg.js"></script>
23 21  
24 22 <!-- Scripts -->
25 23 <script src="/static/jquery/jquery.min.js"></script>
... ...
perguntations/test.py
... ... @@ -31,60 +31,59 @@ class TestFactory(dict):
31 31 # Base questions are added to a pool of questions factories.
32 32 # ------------------------------------------------------------------------
33 33 def __init__(self, conf):
34   - # --- set test configutation and defaults
  34 + # --- set test defaults and then use given configuration
35 35 super().__init__({ # defaults
36 36 'title': '',
37 37 'show_points': False,
38 38 'scale_points': True,
39 39 'scale_max': 20.0,
40   - 'duration': 0, # infinite
  40 + 'scale_min': 0.0,
  41 + 'duration': 0, # 0=infinite
41 42 'debug': False,
42 43 'show_ref': False
43 44 })
44 45 self.update(conf)
45 46  
46 47 # --- perform sanity checks and normalize the test questions
47   - try:
48   - self.sanity_checks()
49   - except TestFactoryException:
50   - logger.critical('Failed sanity checks in test factory.')
51   - raise
  48 + self.sanity_checks()
  49 + logger.info('Sanity checks PASSED.')
  50 +
  51 + # --- find refs of all questions used in the test
  52 + qrefs = {r for qq in self['questions'] for r in qq['ref']}
  53 + logger.info(f'Declared {len(qrefs)} questions (each test uses {len(self["questions"])}).')
52 54  
53 55 # --- for review, we are done. no factories needed
54 56 if self['review']:
55 57 logger.info('Review mode. No questions loaded. No factories.')
56 58 return
57 59  
58   - # --- find all questions in the test that need a factory
59   - qrefs = [r for q in self['questions'] for r in q['ref']]
60   - logger.debug(f'[TestFactory.__init__] test has {len(qrefs)} questions')
61   -
62 60 # --- load and build question factories
63 61 self.question_factory = {}
64 62  
65   - n, nerr = 0, 0
  63 + n = 1
66 64 for file in self["files"]:
67 65 fullpath = path.normpath(path.join(self["questions_dir"], file))
68 66 (dirname, filename) = path.split(fullpath)
69 67  
70   - questions = load_yaml(fullpath, default=[])
  68 + logger.info(f'Loading "{fullpath}"...')
  69 + questions = load_yaml(fullpath) # , default=[])
71 70  
72 71 for i, q in enumerate(questions):
73 72 # make sure every question in the file is a dictionary
74 73 if not isinstance(q, dict):
75   - logger.critical(f'Question {file}:{i} not a dictionary!')
76   - raise TestFactoryException()
  74 + raise TestFactoryException(f'Question {i} in {file} is not a dictionary!')
77 75  
78   - # if ref is missing, then set to '/path/file.yaml:3'
79   - q.setdefault('ref', f'{file}:{i}')
  76 + # check if ref is missing, then set to '/path/file.yaml:3'
  77 + if 'ref' not in q:
  78 + q['ref'] = f'{file}:{i:04}'
  79 + logger.warning(f'Missing "ref" set to "{q["ref"]}"')
80 80  
81 81 # check for duplicate refs
82 82 if q['ref'] in self.question_factory:
83 83 other = self.question_factory[q['ref']]
84   - otherfile = path.join(other['path'], other['filename'])
85   - logger.critical(f'Duplicate reference {q["ref"]} in files '
86   - f'{otherfile} and {fullpath}.')
87   - raise TestFactoryException()
  84 + otherfile = path.join(other.question['path'], other.question['filename'])
  85 + raise TestFactoryException(f'Duplicate reference "{q["ref"]}" in files '
  86 + f'"{otherfile}" and "{fullpath}".')
88 87  
89 88 # make factory only for the questions used in the test
90 89 if q['ref'] in qrefs:
... ... @@ -100,77 +99,73 @@ class TestFactory(dict):
100 99 # check if all the questions can be correctly generated
101 100 try:
102 101 self.question_factory[q['ref']].generate()
103   - except Exception:
104   - logger.error(f'Failed to generate "{q["ref"]}".')
105   - nerr += 1
  102 + except Exception as e:
  103 + raise TestFactoryException(f'Failed to generate "{q["ref"]}"')
106 104 else:
107 105 logger.info(f'{n:4}. "{q["ref"]}" Ok.')
108 106 n += 1
109 107  
110   - if nerr > 0:
111   - logger.critical(f'Found {nerr} errors generating questions.')
112   - raise TestFactoryException()
113   - else:
114   - logger.info(f'No errors found. Ready for "{self["ref"]}".')
  108 + qmissing = qrefs.difference(set(self.question_factory.keys()))
  109 + if qmissing:
  110 + raise TestFactoryException(f'Could not find questions {qmissing}.')
  111 +
115 112  
116 113 # ------------------------------------------------------------------------
117 114 # Checks for valid keys and sets default values.
118 115 # Also checks if some files and directories exist
119 116 # ------------------------------------------------------------------------
120 117 def sanity_checks(self):
121   - # --- database
  118 + # --- ref
  119 + if 'ref' not in self:
  120 + raise TestFactoryException('Missing "ref" in configuration!')
  121 +
  122 + # --- check database
122 123 if 'database' not in self:
123   - logger.critical('Missing "database" in configuration.')
124   - raise TestFactoryException()
  124 + raise TestFactoryException('Missing "database" in configuration!')
125 125 elif not path.isfile(path.expanduser(self['database'])):
126   - logger.critical(f'Can\'t find database {self["database"]}.')
127   - raise TestFactoryException()
  126 + raise TestFactoryException(f'Database {self["database"]} not found!')
128 127  
129   - # --- answers_dir
  128 + # --- check answers_dir
130 129 if 'answers_dir' not in self:
131   - logger.critical('Missing "answers_dir".')
132   - raise TestFactoryException()
  130 + raise TestFactoryException('Missing "answers_dir" in configuration!')
133 131  
  132 + # --- check if answers_dir is a writable directory
134 133 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
135   - try: # check if answers_dir is a writable directory
  134 + try:
136 135 with open(testfile, 'w') as f:
137 136 f.write('You can safely remove this file.')
138 137 except OSError:
139   - logger.critical(f'Can\'t write answers to "{self["answers_dir"]}"')
140   - raise TestFactoryException()
  138 + raise TestFactoryException(f'Cannot write answers to directory "{self["answers_dir"]}"!')
141 139  
142   - # --- ref
143   - if 'ref' not in self:
144   - logger.warning('Missing "ref". Will use filename.')
145   - self['ref'] = self['filename']
  140 + # --- check title
  141 + if not self['title']:
  142 + logger.warning('Undefined title!')
  143 +
  144 + if self['scale_points']:
  145 + logger.info(f'Grades are scaled to the interval [{self["scale_min"]}, {self["scale_max"]}]')
  146 + else:
  147 + logger.info('Grades are just the sum of points defined for the questions, not being scaled.')
146 148  
147 149 # --- questions_dir
148 150 if 'questions_dir' not in self:
149 151 logger.warning(f'Missing "questions_dir". '
150   - f'Using {path.abspath(path.curdir)}')
  152 + f'Using "{path.abspath(path.curdir)}"')
151 153 self['questions_dir'] = path.curdir
152 154 elif not path.isdir(path.expanduser(self['questions_dir'])):
153   - logger.critical(f'Can\'t find questions directory '
154   - f'"{self["questions_dir"]}"')
155   - raise TestFactoryException()
  155 + raise TestFactoryException(f'Can\'t find questions directory '
  156 + f'"{self["questions_dir"]}"')
156 157  
157 158 # --- files
158 159 if 'files' not in self:
159   - logger.warning('Missing "files" key. Loading all YAML files from '
160   - '"questions_dir"... DANGEROUS!!!')
161   - try:
162   - self['files'] = fnmatch.filter(listdir(self['questions_dir']),
163   - '*.yaml')
164   - except OSError:
165   - logger.critical('Couldn\'t get list of YAML question files.')
166   - raise TestFactoryException()
  160 + raise TestFactoryException('Missing "files" in configuration with the list of question files to import!')
  161 + # FIXME allow no files and define the questions directly in the test
  162 +
167 163 if isinstance(self['files'], str):
168 164 self['files'] = [self['files']]
169 165  
170 166 # --- questions
171 167 if 'questions' not in self:
172   - logger.critical(f'Missing "questions" in {self["filename"]}.')
173   - raise TestFactoryException()
  168 + raise TestFactoryException(f'Missing "questions" in Configuration!')
174 169  
175 170 for i, q in enumerate(self['questions']):
176 171 # normalize question to a dict and ref to a list of references
... ... @@ -178,6 +173,8 @@ class TestFactory(dict):
178 173 q = {'ref': [q]} # becomes - ref: [some_ref]
179 174 elif isinstance(q, dict) and isinstance(q['ref'], str):
180 175 q['ref'] = [q['ref']]
  176 + elif isinstance(q, list):
  177 + q = {'ref': [str(a) for a in q]}
181 178  
182 179 self['questions'][i] = q
183 180  
... ... @@ -186,9 +183,8 @@ class TestFactory(dict):
186 183 # returns instance of Test() for that particular student
187 184 # ------------------------------------------------------------------------
188 185 async def generate(self, student):
  186 + # make list of questions
189 187 test = []
190   - # total_points = 0.0
191   -
192 188 n = 1 # track question number
193 189 nerr = 0 # count errors generating questions
194 190  
... ... @@ -198,7 +194,7 @@ class TestFactory(dict):
198 194  
199 195 # generate instance of question
200 196 try:
201   - q = await self.question_factory[qref].generate_async()
  197 + q = await self.question_factory[qref].gen_async()
202 198 except Exception:
203 199 logger.error(f'Can\'t generate question "{qref}". Skipping.')
204 200 nerr += 1
... ... @@ -217,12 +213,12 @@ class TestFactory(dict):
217 213 # normalize question points to scale
218 214 if self['scale_points']:
219 215 total_points = sum(q['points'] for q in test)
220   - for q in test:
221   - try:
222   - q['points'] *= self['scale_max'] / total_points
223   - except ZeroDivisionError:
224   - logger.warning('Total points in the test is 0.0!!!')
225   - q['points'] = 0.0
  216 + if total_points == 0:
  217 + logger.warning('Can\'t scale, total points in the test is 0!')
  218 + else:
  219 + scale = (self['scale_max'] - self['scale_min']) / total_points
  220 + for q in test:
  221 + q['points'] *= scale
226 222  
227 223 if nerr > 0:
228 224 logger.error(f'{nerr} errors found!')
... ... @@ -234,6 +230,8 @@ class TestFactory(dict):
234 230 'questions': test, # list of Question instances
235 231 'answers_dir': self['answers_dir'],
236 232 'duration': self['duration'],
  233 + 'scale_min': self['scale_min'],
  234 + 'scale_max': self['scale_max'],
237 235 'show_points': self['show_points'],
238 236 'show_ref': self['show_ref'],
239 237 'debug': self['debug'], # required by template test.html
... ... @@ -267,8 +265,8 @@ class Test(dict):
267 265 q['answer'] = None
268 266  
269 267 # ------------------------------------------------------------------------
270   - # Given a dictionary ans={'ref': 'some answer'} updates the
271   - # answers of the test. Only affects questions referred.
  268 + # Given a dictionary ans={'ref': 'some answer'} updates the answers of the
  269 + # test. Only affects the questions referred in the dictionary.
272 270 def update_answers(self, ans):
273 271 for ref, answer in ans.items():
274 272 self['questions'][ref]['answer'] = answer
... ... @@ -284,7 +282,8 @@ class Test(dict):
284 282 grade += q['grade'] * q['points']
285 283 logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%')
286 284  
287   - self['grade'] = max(0, round(grade, 1)) # truncate negative grades
  285 + # truncate to avoid negative grade and adjust scale
  286 + self['grade'] = max(0.0, grade) + self['scale_min']
288 287 return self['grade']
289 288  
290 289 # ------------------------------------------------------------------------
... ...
perguntations/tools.py
... ... @@ -21,26 +21,22 @@ def load_yaml(filename: str, default: Any = None) -&gt; Any:
21 21 filename = path.expanduser(filename)
22 22 try:
23 23 f = open(filename, 'r', encoding='utf-8')
24   - except FileNotFoundError:
25   - logger.error(f'Cannot open "{filename}": not found')
26   - except PermissionError:
27   - logger.error(f'Cannot open "{filename}": no permission')
28   - except OSError:
29   - logger.error(f'Cannot open file "{filename}"')
30   - else:
31   - with f:
32   - try:
33   - default = yaml.safe_load(f)
34   - except yaml.YAMLError as e:
35   - if hasattr(e, 'problem_mark'):
36   - mark = e.problem_mark
37   - logger.error(f'File "{filename}" near line {mark.line}, '
38   - f'column {mark.column+1}')
39   - else:
40   - logger.error(f'File "{filename}"')
41   - finally:
42   - return default
  24 + except Exception as e:
  25 + logger.error(e)
  26 + if default is not None:
  27 + return default
  28 + else:
  29 + raise
43 30  
  31 + with f:
  32 + try:
  33 + return yaml.safe_load(f)
  34 + except yaml.YAMLError as e:
  35 + logger.error(str(e).replace('\n', ' '))
  36 + if default is not None:
  37 + return default
  38 + else:
  39 + raise
44 40  
45 41 # ---------------------------------------------------------------------------
46 42 # Runs a script and returns its stdout parsed as yaml, or None on error.
... ...