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