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.
1 1
2 # BUGS 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 - 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 - 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 - ordenacao das notas em /admin nao é numerica, é ascii... 7 - ordenacao das notas em /admin nao é numerica, é ascii...
9 - 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. 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 - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente? 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,10 +15,11 @@ ou usar push (websockets?)
16 - servidor nao esta a lidar com eventos scroll/resize. ignorar? 15 - servidor nao esta a lidar com eventos scroll/resize. ignorar?
17 - Test.reset_answers() unused. 16 - Test.reset_answers() unused.
18 - mudar ref do test para test_id (ref já é usado nas perguntas) 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 # TODO 20 # TODO
21 21
22 -- nao esta a usar points das perguntas 22 +- testar as perguntas todas no início do teste.
23 - test: mostrar duração do teste com progressbar no navbar. 23 - test: mostrar duração do teste com progressbar no navbar.
24 - submissao fazer um post ajax? 24 - submissao fazer um post ajax?
25 - adicionar opcao para eliminar um teste em curso. 25 - adicionar opcao para eliminar um teste em curso.
@@ -59,6 +59,10 @@ ou usar push (websockets?) @@ -59,6 +59,10 @@ ou usar push (websockets?)
59 59
60 # FIXED 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 - quando se clica no texto de uma opcao, salta para outro lado na pagina. 66 - quando se clica no texto de uma opcao, salta para outro lado na pagina.
63 - 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. 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 - fazer package para instalar perguntations com pip. 68 - fazer package para instalar perguntations com pip.
1 # User Manual 1 # User Manual
2 2
3 1. [Requirements](#requirements) 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,7 +260,7 @@ Solutions:
260 260
261 --- 261 ---
262 262
263 -## Contribute 263 +## Please contribute
264 264
265 - Writing questions in yaml format 265 - Writing questions in yaml format
266 - Testing and reporting bugs 266 - Testing and reporting bugs
demo/demo.yaml
1 --- 1 ---
2 # ============================================================================ 2 # ============================================================================
3 # The test reference should be a unique identifier. It is saved in the database 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 ref: tutorial 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 # Database with student credentials and grades of all questions and tests done 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 answers_dir: ans 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 show_points: true 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 # Base path applied to the questions files and all the scripts 37 # Base path applied to the questions files and all the scripts
demo/questions/questions-tutorial.yaml
@@ -17,15 +17,16 @@ @@ -17,15 +17,16 @@
17 # -------------------------------------------------------------------------- 17 # --------------------------------------------------------------------------
18 ref: tutorial # referência, pode ser reusada em vários turnos 18 ref: tutorial # referência, pode ser reusada em vários turnos
19 title: Demonstração # título da prova 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 # opcional 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 questions_dir: ~/topics # raíz da árvore de directórios das perguntas 32 questions_dir: ~/topics # raíz da árvore de directórios das perguntas
@@ -46,25 +47,24 @@ @@ -46,25 +47,24 @@
46 points: 3.5 47 points: 3.5
47 48
48 - ref: pergunta2 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 # escolhe aleatoriamente uma das variantes 58 # escolhe aleatoriamente uma das variantes
54 - ref: [pergunta3a, pergunta3b] 59 - ref: [pergunta3a, pergunta3b]
55 points: 0.5 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 necessário alterar nada. 68 necessário alterar nada.
69 69
70 # ---------------------------------------------------------------------------- 70 # ----------------------------------------------------------------------------
@@ -73,14 +73,14 @@ @@ -73,14 +73,14 @@
73 title: Especificação das perguntas 73 title: Especificação das perguntas
74 text: | 74 text: |
75 As perguntas estão definidas num ou mais ficheiros `yaml` como uma lista de 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 Por exemplo, um ficheiro com o conteúdo abaixo contém duas perguntas, uma 78 Por exemplo, um ficheiro com o conteúdo abaixo contém duas perguntas, uma
79 de escolha múltipla e outra apenas informativa: 79 de escolha múltipla e outra apenas informativa:
80 80
81 ```yaml 81 ```yaml
82 --- 82 ---
83 - #----------------------------------------------------------------------------- 83 + #---------------------------------------------------------------------------
84 - type: radio 84 - type: radio
85 ref: chave-unica-1 85 ref: chave-unica-1
86 text: Quanto é $1+1$? 86 text: Quanto é $1+1$?
@@ -89,7 +89,7 @@ @@ -89,7 +89,7 @@
89 - 2 89 - 2
90 - 3 90 - 3
91 91
92 - #----------------------------------------------------------------------------- 92 + #---------------------------------------------------------------------------
93 - type: information 93 - type: information
94 ref: chave-unica-2 94 ref: chave-unica-2
95 text: | 95 text: |
@@ -98,13 +98,15 @@ @@ -98,13 +98,15 @@
98 texto. 98 texto.
99 É o caso desta pergunta. 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 - type: radio 112 - type: radio
@@ -144,13 +146,14 @@ @@ -144,13 +146,14 @@
144 choose: 3 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 Os valores em `correct` representam o grau de correcção no intervalo [0, 1] 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 Por defeito, as opções são apresentadas por ordem aleatória. 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 ```yaml 158 ```yaml
156 shuffle: false 159 shuffle: false
@@ -200,7 +203,7 @@ @@ -200,7 +203,7 @@
200 - Opção 2 203 - Opção 2
201 - Opção 3 (certa) 204 - Opção 3 (certa)
202 - Opção 4 205 - Opção 4
203 - correct: [1, -1, -1, 1, -1] 206 + correct: [+1, -1, -1, +1, -1]
204 ``` 207 ```
205 208
206 Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada 209 Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada
@@ -209,10 +212,10 @@ @@ -209,10 +212,10 @@
209 Por exemplo se não seleccionar a opção 0, tem cotação -1, e não 212 Por exemplo se não seleccionar a opção 0, tem cotação -1, e não
210 seleccionando a opção 1 obtém-se +1. 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 Cada opção pode opcionalmente ser escrita como uma afirmação e o seu 220 Cada opção pode opcionalmente ser escrita como uma afirmação e o seu
218 contrário, de maneira a aumentar a variabilidade dos textos. 221 contrário, de maneira a aumentar a variabilidade dos textos.
@@ -282,6 +285,16 @@ @@ -282,6 +285,16 @@
282 285
283 Neste exemplo a expressão regular é `(VERDE|[Vv]erde)`. 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 **Atenção:** A expressão regular deve seguir as convenções da suportadas em 300 **Atenção:** A expressão regular deve seguir as convenções da suportadas em
@@ -289,7 +302,8 @@ @@ -289,7 +302,8 @@
289 [Regular expression operations](https://docs.python.org/3/library/re.html)). 302 [Regular expression operations](https://docs.python.org/3/library/re.html)).
290 Em particular, a expressão regular acima também aceita a resposta 303 Em particular, a expressão regular acima também aceita a resposta
291 `verde, azul`. 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 correct: '(VERDE|[Vv]erde)' 308 correct: '(VERDE|[Vv]erde)'
295 309
@@ -311,7 +325,8 @@ @@ -311,7 +325,8 @@
311 correct: [3.14, 3.15] 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 **Atenção:** as respostas têm de usar o ponto como separador decimal. 331 **Atenção:** as respostas têm de usar o ponto como separador decimal.
317 Em geral são aceites números inteiros, como `123`, 332 Em geral são aceites números inteiros, como `123`,
@@ -349,9 +364,8 @@ @@ -349,9 +364,8 @@
349 Neste exemplo, o programa de avaliação é um script python que verifica se a 364 Neste exemplo, o programa de avaliação é um script python que verifica se a
350 resposta contém as três palavras red, green e blue, e calcula uma nota no 365 resposta contém as três palavras red, green e blue, e calcula uma nota no
351 intervalo 0.0 a 1.0. 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 Se o programa externo exceder o `timeout` indicado (em segundos), 370 Se o programa externo exceder o `timeout` indicado (em segundos),
357 este é automaticamente terminado e é atribuída a classificação de 0.0 371 este é automaticamente terminado e é atribuída a classificação de 0.0
@@ -424,9 +438,11 @@ @@ -424,9 +438,11 @@
424 ref: tut-success 438 ref: tut-success
425 title: Texto informativo (sucesso) 439 title: Texto informativo (sucesso)
426 text: | 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 ```C 447 ```C
432 int main() { 448 int main() {
@@ -437,21 +453,25 @@ @@ -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 - type: warning 477 - type: warning
@@ -468,7 +488,8 @@ @@ -468,7 +488,8 @@
468 **world** | $\frac{1}{2\pi}$ | $12.50 488 **world** | $\frac{1}{2\pi}$ | $12.50
469 `code` | $\sqrt{\pi}$ | $1.99 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,6 +526,11 @@
505 526
506 # ---------------------------------------------------------------------------- 527 # ----------------------------------------------------------------------------
507 - type: information 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,9 +13,9 @@
13 "integrity": "sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag==" 13 "integrity": "sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag=="
14 }, 14 },
15 "codemirror": { 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 "commander": { 20 "commander": {
21 "version": "3.0.1", 21 "version": "3.0.1",
@@ -4,7 +4,7 @@ @@ -4,7 +4,7 @@
4 "dependencies": { 4 "dependencies": {
5 "@fortawesome/fontawesome-free": "^5.11.2", 5 "@fortawesome/fontawesome-free": "^5.11.2",
6 "bootstrap": "^4.3", 6 "bootstrap": "^4.3",
7 - "codemirror": "^5.49.0", 7 + "codemirror": "^5.49.2",
8 "datatables": "^1.10", 8 "datatables": "^1.10",
9 "jquery": "^3.4.1", 9 "jquery": "^3.4.1",
10 "mathjax": "^3", 10 "mathjax": "^3",
perguntations/app.py
@@ -62,21 +62,21 @@ class App(object): @@ -62,21 +62,21 @@ class App(object):
62 62
63 # ------------------------------------------------------------------------ 63 # ------------------------------------------------------------------------
64 def __init__(self, conf={}): 64 def __init__(self, conf={}):
65 - logger.info('Starting application.')  
66 self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...} 65 self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...}
67 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere 66 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere
68 67
69 logger.info(f'Loading test configuration "{conf["filename"]}".') 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 # start test factory 72 # start test factory
74 - logger.info(f'Creating test factory.')  
75 try: 73 try:
76 self.testfactory = TestFactory(testconf) 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 # connect to database and check registered students 81 # connect to database and check registered students
82 dbfile = self.testfactory['database'] 82 dbfile = self.testfactory['database']
@@ -87,8 +87,7 @@ class App(object): @@ -87,8 +87,7 @@ class App(object):
87 with self.db_session() as s: 87 with self.db_session() as s:
88 n = s.query(Student).filter(Student.id != '0').count() 88 n = s.query(Student).filter(Student.id != '0').count()
89 except Exception: 89 except Exception:
90 - logger.critical(f'Database unusable {dbfile}.')  
91 - raise AppException() 90 + raise AppException(f'Database unusable {dbfile}.')
92 else: 91 else:
93 logger.info(f'Database {dbfile} has {n} students.') 92 logger.info(f'Database {dbfile} has {n} students.')
94 93
@@ -99,11 +98,12 @@ class App(object): @@ -99,11 +98,12 @@ class App(object):
99 self.allow_student(student[0]) 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 async def login(self, uid, try_pw): 109 async def login(self, uid, try_pw):
@@ -165,7 +165,7 @@ class App(object): @@ -165,7 +165,7 @@ class App(object):
165 logger.info(f'Student {uid}: {len(ans)} answers submitted.') 165 logger.info(f'Student {uid}: {len(ans)} answers submitted.')
166 166
167 grade = await t.correct() 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 # --- save test in JSON format 170 # --- save test in JSON format
171 fields = (uid, t['ref'], str(t['finish_time'])) 171 fields = (uid, t['ref'], str(t['finish_time']))
perguntations/questions.py
1 1
2 # python standard library 2 # python standard library
  3 +import asyncio
3 import random 4 import random
4 import re 5 import re
5 from os import path 6 from os import path
@@ -21,10 +22,10 @@ class QuestionException(Exception): @@ -21,10 +22,10 @@ class QuestionException(Exception):
21 pass 22 pass
22 23
23 24
24 -# =========================================================================== 25 +# ============================================================================
25 # Questions derived from Question are already instantiated and ready to be 26 # Questions derived from Question are already instantiated and ready to be
26 # presented to students. 27 # presented to students.
27 -# =========================================================================== 28 +# ============================================================================
28 class Question(dict): 29 class Question(dict):
29 ''' 30 '''
30 Classes derived from this base class are meant to instantiate questions 31 Classes derived from this base class are meant to instantiate questions
@@ -41,7 +42,7 @@ class Question(dict): @@ -41,7 +42,7 @@ class Question(dict):
41 'comments': '', 42 'comments': '',
42 'solution': '', 43 'solution': '',
43 'files': {}, 44 'files': {},
44 - 'max_tries': 3, 45 + # 'max_tries': 3,
45 })) 46 }))
46 47
47 def correct(self) -> None: 48 def correct(self) -> None:
@@ -57,7 +58,7 @@ class Question(dict): @@ -57,7 +58,7 @@ class Question(dict):
57 self.setdefault(k, v) 58 self.setdefault(k, v)
58 59
59 60
60 -# ========================================================================== 61 +# ============================================================================
61 class QuestionRadio(Question): 62 class QuestionRadio(Question):
62 '''An instance of QuestionRadio will always have the keys: 63 '''An instance of QuestionRadio will always have the keys:
63 type (str) 64 type (str)
@@ -92,7 +93,7 @@ class QuestionRadio(Question): @@ -92,7 +93,7 @@ class QuestionRadio(Question):
92 for x in range(n)] 93 for x in range(n)]
93 94
94 if len(self['correct']) != n: 95 if len(self['correct']) != n:
95 - msg = (f'Options and correct mismatch in ' 96 + msg = ('Number of options and correct differ in '
96 f'"{self["ref"]}", file "{self["filename"]}".') 97 f'"{self["ref"]}", file "{self["filename"]}".')
97 logger.error(msg) 98 logger.error(msg)
98 raise QuestionException(msg) 99 raise QuestionException(msg)
@@ -138,7 +139,7 @@ class QuestionRadio(Question): @@ -138,7 +139,7 @@ class QuestionRadio(Question):
138 self['grade'] = x 139 self['grade'] = x
139 140
140 141
141 -# =========================================================================== 142 +# ============================================================================
142 class QuestionCheckbox(Question): 143 class QuestionCheckbox(Question):
143 '''An instance of QuestionCheckbox will always have the keys: 144 '''An instance of QuestionCheckbox will always have the keys:
144 type (str) 145 type (str)
@@ -218,7 +219,7 @@ class QuestionCheckbox(Question): @@ -218,7 +219,7 @@ class QuestionCheckbox(Question):
218 self['grade'] = x / sum_abs 219 self['grade'] = x / sum_abs
219 220
220 221
221 -# =========================================================================== 222 +# ============================================================================
222 class QuestionText(Question): 223 class QuestionText(Question):
223 '''An instance of QuestionText will always have the keys: 224 '''An instance of QuestionText will always have the keys:
224 type (str) 225 type (str)
@@ -251,13 +252,16 @@ class QuestionText(Question): @@ -251,13 +252,16 @@ class QuestionText(Question):
251 self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0 252 self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0
252 253
253 254
254 -# =========================================================================== 255 +# ============================================================================
255 class QuestionTextRegex(Question): 256 class QuestionTextRegex(Question):
256 '''An instance of QuestionTextRegex will always have the keys: 257 '''An instance of QuestionTextRegex will always have the keys:
257 type (str) 258 type (str)
258 text (str) 259 text (str)
259 - correct (str with regex) 260 + correct (str or list[str])
260 answer (None or an actual answer) 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,22 +270,32 @@ class QuestionTextRegex(Question):
266 270
267 self.set_defaults(QDict({ 271 self.set_defaults(QDict({
268 'text': '', 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 def correct(self) -> None: 284 def correct(self) -> None:
274 super().correct() 285 super().correct()
275 if self['answer'] is not None: 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 class QuestionNumericInterval(Question): 299 class QuestionNumericInterval(Question):
286 '''An instance of QuestionTextNumeric will always have the keys: 300 '''An instance of QuestionTextNumeric will always have the keys:
287 type (str) 301 type (str)
@@ -316,7 +330,7 @@ class QuestionNumericInterval(Question): @@ -316,7 +330,7 @@ class QuestionNumericInterval(Question):
316 self['grade'] = 1.0 if lower <= answer <= upper else 0.0 330 self['grade'] = 1.0 if lower <= answer <= upper else 0.0
317 331
318 332
319 -# =========================================================================== 333 +# ============================================================================
320 class QuestionTextArea(Question): 334 class QuestionTextArea(Question):
321 '''An instance of QuestionTextArea will always have the keys: 335 '''An instance of QuestionTextArea will always have the keys:
322 type (str) 336 type (str)
@@ -397,7 +411,7 @@ class QuestionTextArea(Question): @@ -397,7 +411,7 @@ class QuestionTextArea(Question):
397 logger.error(f'Invalid grade in "{self["correct"]}".') 411 logger.error(f'Invalid grade in "{self["correct"]}".')
398 412
399 413
400 -# =========================================================================== 414 +# ============================================================================
401 class QuestionInformation(Question): 415 class QuestionInformation(Question):
402 # ------------------------------------------------------------------------ 416 # ------------------------------------------------------------------------
403 def __init__(self, q: QDict) -> None: 417 def __init__(self, q: QDict) -> None:
@@ -412,30 +426,38 @@ class QuestionInformation(Question): @@ -412,30 +426,38 @@ class QuestionInformation(Question):
412 self['grade'] = 1.0 # always "correct" but points should be zero! 426 self['grade'] = 1.0 # always "correct" but points should be zero!
413 427
414 428
415 -# =========================================================================== 429 +# ============================================================================
  430 +#
416 # QFactory is a class that can generate question instances, e.g. by shuffling 431 # QFactory is a class that can generate question instances, e.g. by shuffling
417 # options, running a script to generate the question, etc. 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 # Example: 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 # 'type': 'radio', 444 # 'type': 'radio',
428 # 'text': 'Choose one', 445 # 'text': 'Choose one',
429 # 'options': ['a', 'b'] 446 # 'options': ['a', 'b']
430 -# }  
431 -# qfactory = QFactory(qdict) 447 +# })
  448 +#
  449 +# # generate synchronously
432 # question = qfactory.generate() 450 # question = qfactory.generate()
433 # 451 #
  452 +# # generate asynchronously
  453 +# question = await qfactory.gen_async()
  454 +#
434 # # answer one question and correct it 455 # # answer one question and correct it
435 # question['answer'] = 42 # set answer 456 # question['answer'] = 42 # set answer
436 # question.correct() # correct answer 457 # question.correct() # correct answer
437 # grade = question['grade'] # get grade 458 # grade = question['grade'] # get grade
438 -# =========================================================================== 459 +#
  460 +# ============================================================================
439 class QFactory(object): 461 class QFactory(object):
440 # Depending on the type of question, a different question class will be 462 # Depending on the type of question, a different question class will be
441 # instantiated. All these classes derive from the base class `Question`. 463 # instantiated. All these classes derive from the base class `Question`.
@@ -456,43 +478,12 @@ class QFactory(object): @@ -456,43 +478,12 @@ class QFactory(object):
456 def __init__(self, qdict: QDict = QDict({})) -> None: 478 def __init__(self, qdict: QDict = QDict({})) -> None:
457 self.question = qdict 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 # Shallow copy so that script generated questions will not replace 487 # Shallow copy so that script generated questions will not replace
497 # the original generators 488 # the original generators
498 q = self.question.copy() 489 q = self.question.copy()
@@ -520,5 +511,8 @@ class QFactory(object): @@ -520,5 +511,8 @@ class QFactory(object):
520 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') 511 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
521 raise 512 raise
522 else: 513 else:
523 - logger.debug(f'[generate_async] Done instance of {q["ref"]}')  
524 return qinstance 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,7 +490,7 @@ def main():
490 490
491 try: 491 try:
492 testapp = App(config) 492 testapp = App(config)
493 - except AppException: 493 + except Exception:
494 logging.critical('Failed to start application.') 494 logging.critical('Failed to start application.')
495 sys.exit(-1) 495 sys.exit(-1)
496 496
perguntations/static/js/admin.js
@@ -70,10 +70,10 @@ $(document).ready(function() { @@ -70,10 +70,10 @@ $(document).ready(function() {
70 else 70 else
71 barcolor = 'bg-success'; 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 + '" role="progressbar" aria-valuenow="' + grade 74 + '" role="progressbar" aria-valuenow="' + grade
75 + '" aria-valuemin="0" aria-valuemax="20" style="min-width: 2em; width: ' 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,69 +39,70 @@
39 </nav> 39 </nav>
40 <!-- ================================================================== --> 40 <!-- ================================================================== -->
41 <div class="container"> 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 {% end %} 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 </div> <!-- container --> 106 </div> <!-- container -->
106 </body> 107 </body>
107 </html> 108 </html>
perguntations/templates/question.html
@@ -18,7 +18,13 @@ @@ -18,7 +18,13 @@
18 18
19 <p class="text-right"> 19 <p class="text-right">
20 <small> 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 </small> 28 </small>
23 </p> 29 </p>
24 </div> 30 </div>
perguntations/templates/review-question.html
@@ -21,7 +21,13 @@ @@ -21,7 +21,13 @@
21 21
22 <p class="text-right"> 22 <p class="text-right">
23 <small> 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 </small> 31 </small>
26 </p> 32 </p>
27 </div> <!-- card-body --> 33 </div> <!-- card-body -->
perguntations/templates/review.html
@@ -95,7 +95,7 @@ @@ -95,7 +95,7 @@
95 <div class="row"> 95 <div class="row">
96 <label for="nota" class="col-sm-2">Nota:</label> 96 <label for="nota" class="col-sm-2">Nota:</label>
97 <div class="col-sm-10" id="nota"> 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 {% if t['state'] == 'QUIT' %} 99 {% if t['state'] == 'QUIT' %}
100 (DESISTÊNCIA) 100 (DESISTÊNCIA)
101 {% end %} 101 {% end %}
perguntations/templates/test.html
@@ -17,9 +17,7 @@ @@ -17,9 +17,7 @@
17 } 17 }
18 }; 18 };
19 </script> 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 <!-- Scripts --> 22 <!-- Scripts -->
25 <script src="/static/jquery/jquery.min.js"></script> 23 <script src="/static/jquery/jquery.min.js"></script>
perguntations/test.py
@@ -31,60 +31,59 @@ class TestFactory(dict): @@ -31,60 +31,59 @@ class TestFactory(dict):
31 # Base questions are added to a pool of questions factories. 31 # Base questions are added to a pool of questions factories.
32 # ------------------------------------------------------------------------ 32 # ------------------------------------------------------------------------
33 def __init__(self, conf): 33 def __init__(self, conf):
34 - # --- set test configutation and defaults 34 + # --- set test defaults and then use given configuration
35 super().__init__({ # defaults 35 super().__init__({ # defaults
36 'title': '', 36 'title': '',
37 'show_points': False, 37 'show_points': False,
38 'scale_points': True, 38 'scale_points': True,
39 'scale_max': 20.0, 39 'scale_max': 20.0,
40 - 'duration': 0, # infinite 40 + 'scale_min': 0.0,
  41 + 'duration': 0, # 0=infinite
41 'debug': False, 42 'debug': False,
42 'show_ref': False 43 'show_ref': False
43 }) 44 })
44 self.update(conf) 45 self.update(conf)
45 46
46 # --- perform sanity checks and normalize the test questions 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 # --- for review, we are done. no factories needed 55 # --- for review, we are done. no factories needed
54 if self['review']: 56 if self['review']:
55 logger.info('Review mode. No questions loaded. No factories.') 57 logger.info('Review mode. No questions loaded. No factories.')
56 return 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 # --- load and build question factories 60 # --- load and build question factories
63 self.question_factory = {} 61 self.question_factory = {}
64 62
65 - n, nerr = 0, 0 63 + n = 1
66 for file in self["files"]: 64 for file in self["files"]:
67 fullpath = path.normpath(path.join(self["questions_dir"], file)) 65 fullpath = path.normpath(path.join(self["questions_dir"], file))
68 (dirname, filename) = path.split(fullpath) 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 for i, q in enumerate(questions): 71 for i, q in enumerate(questions):
73 # make sure every question in the file is a dictionary 72 # make sure every question in the file is a dictionary
74 if not isinstance(q, dict): 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 # check for duplicate refs 81 # check for duplicate refs
82 if q['ref'] in self.question_factory: 82 if q['ref'] in self.question_factory:
83 other = self.question_factory[q['ref']] 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 # make factory only for the questions used in the test 88 # make factory only for the questions used in the test
90 if q['ref'] in qrefs: 89 if q['ref'] in qrefs:
@@ -100,77 +99,73 @@ class TestFactory(dict): @@ -100,77 +99,73 @@ class TestFactory(dict):
100 # check if all the questions can be correctly generated 99 # check if all the questions can be correctly generated
101 try: 100 try:
102 self.question_factory[q['ref']].generate() 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 else: 104 else:
107 logger.info(f'{n:4}. "{q["ref"]}" Ok.') 105 logger.info(f'{n:4}. "{q["ref"]}" Ok.')
108 n += 1 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 # Checks for valid keys and sets default values. 114 # Checks for valid keys and sets default values.
118 # Also checks if some files and directories exist 115 # Also checks if some files and directories exist
119 # ------------------------------------------------------------------------ 116 # ------------------------------------------------------------------------
120 def sanity_checks(self): 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 if 'database' not in self: 123 if 'database' not in self:
123 - logger.critical('Missing "database" in configuration.')  
124 - raise TestFactoryException() 124 + raise TestFactoryException('Missing "database" in configuration!')
125 elif not path.isfile(path.expanduser(self['database'])): 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 if 'answers_dir' not in self: 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 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') 133 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
135 - try: # check if answers_dir is a writable directory 134 + try:
136 with open(testfile, 'w') as f: 135 with open(testfile, 'w') as f:
137 f.write('You can safely remove this file.') 136 f.write('You can safely remove this file.')
138 except OSError: 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 # --- questions_dir 149 # --- questions_dir
148 if 'questions_dir' not in self: 150 if 'questions_dir' not in self:
149 logger.warning(f'Missing "questions_dir". ' 151 logger.warning(f'Missing "questions_dir". '
150 - f'Using {path.abspath(path.curdir)}') 152 + f'Using "{path.abspath(path.curdir)}"')
151 self['questions_dir'] = path.curdir 153 self['questions_dir'] = path.curdir
152 elif not path.isdir(path.expanduser(self['questions_dir'])): 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 # --- files 158 # --- files
158 if 'files' not in self: 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 if isinstance(self['files'], str): 163 if isinstance(self['files'], str):
168 self['files'] = [self['files']] 164 self['files'] = [self['files']]
169 165
170 # --- questions 166 # --- questions
171 if 'questions' not in self: 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 for i, q in enumerate(self['questions']): 170 for i, q in enumerate(self['questions']):
176 # normalize question to a dict and ref to a list of references 171 # normalize question to a dict and ref to a list of references
@@ -178,6 +173,8 @@ class TestFactory(dict): @@ -178,6 +173,8 @@ class TestFactory(dict):
178 q = {'ref': [q]} # becomes - ref: [some_ref] 173 q = {'ref': [q]} # becomes - ref: [some_ref]
179 elif isinstance(q, dict) and isinstance(q['ref'], str): 174 elif isinstance(q, dict) and isinstance(q['ref'], str):
180 q['ref'] = [q['ref']] 175 q['ref'] = [q['ref']]
  176 + elif isinstance(q, list):
  177 + q = {'ref': [str(a) for a in q]}
181 178
182 self['questions'][i] = q 179 self['questions'][i] = q
183 180
@@ -186,9 +183,8 @@ class TestFactory(dict): @@ -186,9 +183,8 @@ class TestFactory(dict):
186 # returns instance of Test() for that particular student 183 # returns instance of Test() for that particular student
187 # ------------------------------------------------------------------------ 184 # ------------------------------------------------------------------------
188 async def generate(self, student): 185 async def generate(self, student):
  186 + # make list of questions
189 test = [] 187 test = []
190 - # total_points = 0.0  
191 -  
192 n = 1 # track question number 188 n = 1 # track question number
193 nerr = 0 # count errors generating questions 189 nerr = 0 # count errors generating questions
194 190
@@ -198,7 +194,7 @@ class TestFactory(dict): @@ -198,7 +194,7 @@ class TestFactory(dict):
198 194
199 # generate instance of question 195 # generate instance of question
200 try: 196 try:
201 - q = await self.question_factory[qref].generate_async() 197 + q = await self.question_factory[qref].gen_async()
202 except Exception: 198 except Exception:
203 logger.error(f'Can\'t generate question "{qref}". Skipping.') 199 logger.error(f'Can\'t generate question "{qref}". Skipping.')
204 nerr += 1 200 nerr += 1
@@ -217,12 +213,12 @@ class TestFactory(dict): @@ -217,12 +213,12 @@ class TestFactory(dict):
217 # normalize question points to scale 213 # normalize question points to scale
218 if self['scale_points']: 214 if self['scale_points']:
219 total_points = sum(q['points'] for q in test) 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 if nerr > 0: 223 if nerr > 0:
228 logger.error(f'{nerr} errors found!') 224 logger.error(f'{nerr} errors found!')
@@ -234,6 +230,8 @@ class TestFactory(dict): @@ -234,6 +230,8 @@ class TestFactory(dict):
234 'questions': test, # list of Question instances 230 'questions': test, # list of Question instances
235 'answers_dir': self['answers_dir'], 231 'answers_dir': self['answers_dir'],
236 'duration': self['duration'], 232 'duration': self['duration'],
  233 + 'scale_min': self['scale_min'],
  234 + 'scale_max': self['scale_max'],
237 'show_points': self['show_points'], 235 'show_points': self['show_points'],
238 'show_ref': self['show_ref'], 236 'show_ref': self['show_ref'],
239 'debug': self['debug'], # required by template test.html 237 'debug': self['debug'], # required by template test.html
@@ -267,8 +265,8 @@ class Test(dict): @@ -267,8 +265,8 @@ class Test(dict):
267 q['answer'] = None 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 def update_answers(self, ans): 270 def update_answers(self, ans):
273 for ref, answer in ans.items(): 271 for ref, answer in ans.items():
274 self['questions'][ref]['answer'] = answer 272 self['questions'][ref]['answer'] = answer
@@ -284,7 +282,8 @@ class Test(dict): @@ -284,7 +282,8 @@ class Test(dict):
284 grade += q['grade'] * q['points'] 282 grade += q['grade'] * q['points']
285 logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%') 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 return self['grade'] 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,26 +21,22 @@ def load_yaml(filename: str, default: Any = None) -&gt; Any:
21 filename = path.expanduser(filename) 21 filename = path.expanduser(filename)
22 try: 22 try:
23 f = open(filename, 'r', encoding='utf-8') 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 # Runs a script and returns its stdout parsed as yaml, or None on error. 42 # Runs a script and returns its stdout parsed as yaml, or None on error.