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 | 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,59 @@ class TestFactory(dict): |
31 | 31 | # Base questions are added to a pool of questions factories. |
32 | 32 | # ------------------------------------------------------------------------ |
33 | 33 | def __init__(self, conf): |
34 | - # --- set test configutation and defaults | |
34 | + # --- set test defaults and then use given configuration | |
35 | 35 | super().__init__({ # defaults |
36 | 36 | 'title': '', |
37 | 37 | 'show_points': False, |
38 | 38 | 'scale_points': True, |
39 | 39 | 'scale_max': 20.0, |
40 | - 'duration': 0, # infinite | |
40 | + 'scale_min': 0.0, | |
41 | + 'duration': 0, # 0=infinite | |
41 | 42 | 'debug': False, |
42 | 43 | 'show_ref': False |
43 | 44 | }) |
44 | 45 | self.update(conf) |
45 | 46 | |
46 | 47 | # --- perform sanity checks and normalize the test questions |
47 | - try: | |
48 | - self.sanity_checks() | |
49 | - except TestFactoryException: | |
50 | - logger.critical('Failed sanity checks in test factory.') | |
51 | - raise | |
48 | + self.sanity_checks() | |
49 | + logger.info('Sanity checks PASSED.') | |
50 | + | |
51 | + # --- find refs of all questions used in the test | |
52 | + qrefs = {r for qq in self['questions'] for r in qq['ref']} | |
53 | + logger.info(f'Declared {len(qrefs)} questions (each test uses {len(self["questions"])}).') | |
52 | 54 | |
53 | 55 | # --- for review, we are done. no factories needed |
54 | 56 | if self['review']: |
55 | 57 | logger.info('Review mode. No questions loaded. No factories.') |
56 | 58 | return |
57 | 59 | |
58 | - # --- find all questions in the test that need a factory | |
59 | - qrefs = [r for q in self['questions'] for r in q['ref']] | |
60 | - logger.debug(f'[TestFactory.__init__] test has {len(qrefs)} questions') | |
61 | - | |
62 | 60 | # --- load and build question factories |
63 | 61 | self.question_factory = {} |
64 | 62 | |
65 | - n, nerr = 0, 0 | |
63 | + n = 1 | |
66 | 64 | for file in self["files"]: |
67 | 65 | fullpath = path.normpath(path.join(self["questions_dir"], file)) |
68 | 66 | (dirname, filename) = path.split(fullpath) |
69 | 67 | |
70 | - questions = load_yaml(fullpath, default=[]) | |
68 | + logger.info(f'Loading "{fullpath}"...') | |
69 | + questions = load_yaml(fullpath) # , default=[]) | |
71 | 70 | |
72 | 71 | for i, q in enumerate(questions): |
73 | 72 | # make sure every question in the file is a dictionary |
74 | 73 | if not isinstance(q, dict): |
75 | - logger.critical(f'Question {file}:{i} not a dictionary!') | |
76 | - raise TestFactoryException() | |
74 | + raise TestFactoryException(f'Question {i} in {file} is not a dictionary!') | |
77 | 75 | |
78 | - # if ref is missing, then set to '/path/file.yaml:3' | |
79 | - q.setdefault('ref', f'{file}:{i}') | |
76 | + # check if ref is missing, then set to '/path/file.yaml:3' | |
77 | + if 'ref' not in q: | |
78 | + q['ref'] = f'{file}:{i:04}' | |
79 | + logger.warning(f'Missing "ref" set to "{q["ref"]}"') | |
80 | 80 | |
81 | 81 | # check for duplicate refs |
82 | 82 | if q['ref'] in self.question_factory: |
83 | 83 | other = self.question_factory[q['ref']] |
84 | - otherfile = path.join(other['path'], other['filename']) | |
85 | - logger.critical(f'Duplicate reference {q["ref"]} in files ' | |
86 | - f'{otherfile} and {fullpath}.') | |
87 | - raise TestFactoryException() | |
84 | + otherfile = path.join(other.question['path'], other.question['filename']) | |
85 | + raise TestFactoryException(f'Duplicate reference "{q["ref"]}" in files ' | |
86 | + f'"{otherfile}" and "{fullpath}".') | |
88 | 87 | |
89 | 88 | # make factory only for the questions used in the test |
90 | 89 | if q['ref'] in qrefs: |
... | ... | @@ -100,77 +99,73 @@ class TestFactory(dict): |
100 | 99 | # check if all the questions can be correctly generated |
101 | 100 | try: |
102 | 101 | self.question_factory[q['ref']].generate() |
103 | - except Exception: | |
104 | - logger.error(f'Failed to generate "{q["ref"]}".') | |
105 | - nerr += 1 | |
102 | + except Exception as e: | |
103 | + raise TestFactoryException(f'Failed to generate "{q["ref"]}"') | |
106 | 104 | else: |
107 | 105 | logger.info(f'{n:4}. "{q["ref"]}" Ok.') |
108 | 106 | n += 1 |
109 | 107 | |
110 | - if nerr > 0: | |
111 | - logger.critical(f'Found {nerr} errors generating questions.') | |
112 | - raise TestFactoryException() | |
113 | - else: | |
114 | - logger.info(f'No errors found. Ready for "{self["ref"]}".') | |
108 | + qmissing = qrefs.difference(set(self.question_factory.keys())) | |
109 | + if qmissing: | |
110 | + raise TestFactoryException(f'Could not find questions {qmissing}.') | |
111 | + | |
115 | 112 | |
116 | 113 | # ------------------------------------------------------------------------ |
117 | 114 | # Checks for valid keys and sets default values. |
118 | 115 | # Also checks if some files and directories exist |
119 | 116 | # ------------------------------------------------------------------------ |
120 | 117 | def sanity_checks(self): |
121 | - # --- database | |
118 | + # --- ref | |
119 | + if 'ref' not in self: | |
120 | + raise TestFactoryException('Missing "ref" in configuration!') | |
121 | + | |
122 | + # --- check database | |
122 | 123 | if 'database' not in self: |
123 | - logger.critical('Missing "database" in configuration.') | |
124 | - raise TestFactoryException() | |
124 | + raise TestFactoryException('Missing "database" in configuration!') | |
125 | 125 | elif not path.isfile(path.expanduser(self['database'])): |
126 | - logger.critical(f'Can\'t find database {self["database"]}.') | |
127 | - raise TestFactoryException() | |
126 | + raise TestFactoryException(f'Database {self["database"]} not found!') | |
128 | 127 | |
129 | - # --- answers_dir | |
128 | + # --- check answers_dir | |
130 | 129 | if 'answers_dir' not in self: |
131 | - logger.critical('Missing "answers_dir".') | |
132 | - raise TestFactoryException() | |
130 | + raise TestFactoryException('Missing "answers_dir" in configuration!') | |
133 | 131 | |
132 | + # --- check if answers_dir is a writable directory | |
134 | 133 | testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') |
135 | - try: # check if answers_dir is a writable directory | |
134 | + try: | |
136 | 135 | with open(testfile, 'w') as f: |
137 | 136 | f.write('You can safely remove this file.') |
138 | 137 | except OSError: |
139 | - logger.critical(f'Can\'t write answers to "{self["answers_dir"]}"') | |
140 | - raise TestFactoryException() | |
138 | + raise TestFactoryException(f'Cannot write answers to directory "{self["answers_dir"]}"!') | |
141 | 139 | |
142 | - # --- ref | |
143 | - if 'ref' not in self: | |
144 | - logger.warning('Missing "ref". Will use filename.') | |
145 | - self['ref'] = self['filename'] | |
140 | + # --- check title | |
141 | + if not self['title']: | |
142 | + logger.warning('Undefined title!') | |
143 | + | |
144 | + if self['scale_points']: | |
145 | + logger.info(f'Grades are scaled to the interval [{self["scale_min"]}, {self["scale_max"]}]') | |
146 | + else: | |
147 | + logger.info('Grades are just the sum of points defined for the questions, not being scaled.') | |
146 | 148 | |
147 | 149 | # --- questions_dir |
148 | 150 | if 'questions_dir' not in self: |
149 | 151 | logger.warning(f'Missing "questions_dir". ' |
150 | - f'Using {path.abspath(path.curdir)}') | |
152 | + f'Using "{path.abspath(path.curdir)}"') | |
151 | 153 | self['questions_dir'] = path.curdir |
152 | 154 | elif not path.isdir(path.expanduser(self['questions_dir'])): |
153 | - logger.critical(f'Can\'t find questions directory ' | |
154 | - f'"{self["questions_dir"]}"') | |
155 | - raise TestFactoryException() | |
155 | + raise TestFactoryException(f'Can\'t find questions directory ' | |
156 | + f'"{self["questions_dir"]}"') | |
156 | 157 | |
157 | 158 | # --- files |
158 | 159 | if 'files' not in self: |
159 | - logger.warning('Missing "files" key. Loading all YAML files from ' | |
160 | - '"questions_dir"... DANGEROUS!!!') | |
161 | - try: | |
162 | - self['files'] = fnmatch.filter(listdir(self['questions_dir']), | |
163 | - '*.yaml') | |
164 | - except OSError: | |
165 | - logger.critical('Couldn\'t get list of YAML question files.') | |
166 | - raise TestFactoryException() | |
160 | + raise TestFactoryException('Missing "files" in configuration with the list of question files to import!') | |
161 | + # FIXME allow no files and define the questions directly in the test | |
162 | + | |
167 | 163 | if isinstance(self['files'], str): |
168 | 164 | self['files'] = [self['files']] |
169 | 165 | |
170 | 166 | # --- questions |
171 | 167 | if 'questions' not in self: |
172 | - logger.critical(f'Missing "questions" in {self["filename"]}.') | |
173 | - raise TestFactoryException() | |
168 | + raise TestFactoryException(f'Missing "questions" in Configuration!') | |
174 | 169 | |
175 | 170 | for i, q in enumerate(self['questions']): |
176 | 171 | # normalize question to a dict and ref to a list of references |
... | ... | @@ -178,6 +173,8 @@ class TestFactory(dict): |
178 | 173 | q = {'ref': [q]} # becomes - ref: [some_ref] |
179 | 174 | elif isinstance(q, dict) and isinstance(q['ref'], str): |
180 | 175 | q['ref'] = [q['ref']] |
176 | + elif isinstance(q, list): | |
177 | + q = {'ref': [str(a) for a in q]} | |
181 | 178 | |
182 | 179 | self['questions'][i] = q |
183 | 180 | |
... | ... | @@ -186,9 +183,8 @@ class TestFactory(dict): |
186 | 183 | # returns instance of Test() for that particular student |
187 | 184 | # ------------------------------------------------------------------------ |
188 | 185 | async def generate(self, student): |
186 | + # make list of questions | |
189 | 187 | test = [] |
190 | - # total_points = 0.0 | |
191 | - | |
192 | 188 | n = 1 # track question number |
193 | 189 | nerr = 0 # count errors generating questions |
194 | 190 | |
... | ... | @@ -198,7 +194,7 @@ class TestFactory(dict): |
198 | 194 | |
199 | 195 | # generate instance of question |
200 | 196 | try: |
201 | - q = await self.question_factory[qref].generate_async() | |
197 | + q = await self.question_factory[qref].gen_async() | |
202 | 198 | except Exception: |
203 | 199 | logger.error(f'Can\'t generate question "{qref}". Skipping.') |
204 | 200 | nerr += 1 |
... | ... | @@ -217,12 +213,12 @@ class TestFactory(dict): |
217 | 213 | # normalize question points to scale |
218 | 214 | if self['scale_points']: |
219 | 215 | total_points = sum(q['points'] for q in test) |
220 | - for q in test: | |
221 | - try: | |
222 | - q['points'] *= self['scale_max'] / total_points | |
223 | - except ZeroDivisionError: | |
224 | - logger.warning('Total points in the test is 0.0!!!') | |
225 | - q['points'] = 0.0 | |
216 | + if total_points == 0: | |
217 | + logger.warning('Can\'t scale, total points in the test is 0!') | |
218 | + else: | |
219 | + scale = (self['scale_max'] - self['scale_min']) / total_points | |
220 | + for q in test: | |
221 | + q['points'] *= scale | |
226 | 222 | |
227 | 223 | if nerr > 0: |
228 | 224 | logger.error(f'{nerr} errors found!') |
... | ... | @@ -234,6 +230,8 @@ class TestFactory(dict): |
234 | 230 | 'questions': test, # list of Question instances |
235 | 231 | 'answers_dir': self['answers_dir'], |
236 | 232 | 'duration': self['duration'], |
233 | + 'scale_min': self['scale_min'], | |
234 | + 'scale_max': self['scale_max'], | |
237 | 235 | 'show_points': self['show_points'], |
238 | 236 | 'show_ref': self['show_ref'], |
239 | 237 | 'debug': self['debug'], # required by template test.html |
... | ... | @@ -267,8 +265,8 @@ class Test(dict): |
267 | 265 | q['answer'] = None |
268 | 266 | |
269 | 267 | # ------------------------------------------------------------------------ |
270 | - # Given a dictionary ans={'ref': 'some answer'} updates the | |
271 | - # answers of the test. Only affects questions referred. | |
268 | + # Given a dictionary ans={'ref': 'some answer'} updates the answers of the | |
269 | + # test. Only affects the questions referred in the dictionary. | |
272 | 270 | def update_answers(self, ans): |
273 | 271 | for ref, answer in ans.items(): |
274 | 272 | self['questions'][ref]['answer'] = answer |
... | ... | @@ -284,7 +282,8 @@ class Test(dict): |
284 | 282 | grade += q['grade'] * q['points'] |
285 | 283 | logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%') |
286 | 284 | |
287 | - self['grade'] = max(0, round(grade, 1)) # truncate negative grades | |
285 | + # truncate to avoid negative grade and adjust scale | |
286 | + self['grade'] = max(0.0, grade) + self['scale_min'] | |
288 | 287 | return self['grade'] |
289 | 288 | |
290 | 289 | # ------------------------------------------------------------------------ | ... | ... |
perguntations/tools.py
... | ... | @@ -21,26 +21,22 @@ def load_yaml(filename: str, default: Any = None) -> 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. | ... | ... |