Commit a80f0415f31d36abac43208a3474dd6227d3e9b7
1 parent
ec1dd9a6
Exists in
master
and in
1 other branch
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.
Showing
17 changed files
with
362 additions
and
323 deletions
Show diff stats
BUGS.md
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. |
README.md
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", |
package.json
@@ -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) -> Any: | @@ -21,26 +21,22 @@ def load_yaml(filename: str, default: Any = None) -> 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. |