Commit 71a43de22472e04822444cd7be5eaf43f56bc17c
1 parent
8431c74d
Exists in
master
and in
1 other branch
- updates to demo.
- shares same questions.py as aprendizations. - rewrite of TestFactory to use the same QFactory as aprendizations. removes factory.py. - fix async functions to always use the same thread (no executor), also fixes sqlalchemy threading errors. - fix textarea lines. - fix checkbox with shuffle set to false. - fix error in scale_points when total points is zero.
Showing
9 changed files
with
571 additions
and
574 deletions
Show diff stats
... | ... | @@ -0,0 +1,57 @@ |
1 | +--- | |
2 | +# ============================================================================ | |
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'" | |
6 | +ref: tutorial | |
7 | + | |
8 | +# (optional, default: '') You may wish to refer the course, year or kind of test | |
9 | +title: Teste de demonstração (tutorial) | |
10 | + | |
11 | +# (optional) duration in minutes, 0 or undefined is infinite | |
12 | +duration: 60 | |
13 | + | |
14 | +# Database with student credentials and grades of all questions and tests done | |
15 | +# The database is an sqlite3 file generate with the script initdb.py | |
16 | +database: students.db | |
17 | + | |
18 | +# Generate a file for each test done by a student. | |
19 | +# It includes the questions, answers and grades. | |
20 | +answers_dir: ans | |
21 | + | |
22 | +# (optional, default: False) Show points for each question, scale 0-20. | |
23 | +show_points: true | |
24 | +# scale_points: true | |
25 | +# scale_max: 20 | |
26 | + | |
27 | +# ---------------------------------------------------------------------------- | |
28 | +# Base path applied to the questions files and all the scripts | |
29 | +# including question generators and correctors. | |
30 | +# Either absolute path or relative to current directory can be used. | |
31 | +questions_dir: . | |
32 | + | |
33 | +# (optional) List of files containing questions in yaml format. | |
34 | +# Selected questions will be obtained from these files. | |
35 | +# If undefined, all yaml files in questions_dir are loaded (not recommended). | |
36 | +files: | |
37 | + - questions/questions-tutorial.yaml | |
38 | + | |
39 | +# This is the list of questions that will make up the test. | |
40 | +# The order is preserved. | |
41 | +# There are several ways to define each question (explained below). | |
42 | +questions: | |
43 | + - tut-test | |
44 | + - tut-questions | |
45 | + | |
46 | + - tut-radio | |
47 | + - tut-checkbox | |
48 | + - tut-text | |
49 | + - tut-text-regex | |
50 | + - tut-numeric-interval | |
51 | + - ref: tut-textarea | |
52 | + points: 2.0 | |
53 | + | |
54 | + - tut-information | |
55 | + - tut-success | |
56 | + - tut-warning | |
57 | + - tut-alert | ... | ... |
demo/questions/questions-tutorial.yaml
... | ... | @@ -4,7 +4,8 @@ |
4 | 4 | ref: tut-test |
5 | 5 | title: Configuração do teste |
6 | 6 | text: | |
7 | - O teste é configurado num ficheiro `yaml` (ver especificação [aqui](https://yaml.org)). | |
7 | + O teste é configurado num ficheiro `yaml` | |
8 | + (ver especificação [aqui](https://yaml.org)). | |
8 | 9 | A configuração contém a identificação do teste, base de dados dos alunos, |
9 | 10 | ficheiros de perguntas a importar e uma selecção de perguntas e respectivas |
10 | 11 | cotações. |
... | ... | @@ -13,44 +14,33 @@ |
13 | 14 | |
14 | 15 | ```yaml |
15 | 16 | --- |
16 | - # ---------------------------------------------------------------------------- | |
17 | - # ref: Referência do teste. Pode ser reusada em vários turnos | |
18 | - # title: Título do teste | |
19 | - # database: Base de dados previamente inicializada com os alunos (usando initdb.py) | |
20 | - # answers_dir: Directório onde vão ficar guardados os testes dos alunos | |
21 | - ref: tutorial | |
22 | - title: Teste de Avaliação | |
23 | - database: demo/students.db | |
24 | - answers_dir: demo/ans | |
25 | - | |
26 | - # Duração do teste em minutos, apenas informativo. (default: infinito) | |
27 | - duration: 60 | |
28 | - | |
29 | - # Mostrar cotação das perguntas. (default: false) | |
30 | - show_points: true | |
31 | - | |
32 | - # Pontos das perguntas são automaticamente convertidos para a escala dada | |
33 | - # (defaults: true, 20) | |
34 | - scale_points: true | |
35 | - scale_max: 20 | |
36 | - | |
37 | - # (opcional, default: false) | |
38 | - debug: false | |
39 | - | |
40 | - # ---------------------------------------------------------------------------- | |
41 | - # Directório base onde estão as perguntas | |
42 | - questions_dir: ~/topics/P1 | |
43 | - | |
44 | - # Ficheiros de perguntas a importar (relativamente a ~/topics/P1) | |
17 | + # -------------------------------------------------------------------------- | |
18 | + ref: tutorial # referência, pode ser reusada em vários turnos | |
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 | |
22 | + | |
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) | |
29 | + | |
30 | + # -------------------------------------------------------------------------- | |
31 | + questions_dir: ~/topics # raíz da árvore de directórios das perguntas | |
32 | + | |
33 | + # Ficheiros de perguntas a importar (relativamente a `questions_dir`) | |
45 | 34 | files: |
46 | - - topico_A/parte_1/questions.yaml | |
47 | - - topico_A/parte_2/questions.yaml | |
48 | - - topico_B/questions.yaml | |
49 | - | |
50 | - # ---------------------------------------------------------------------------- | |
51 | - # Especificação das perguntas do teste e cotações. | |
52 | - # O teste é uma lista de perguntas | |
53 | - # Cada pergunta é um dicionário com ref da pergunta e cotação. | |
35 | + - tabelas.yaml | |
36 | + - topic_A/questions.yaml | |
37 | + - topic_B/part_1/questions.yaml | |
38 | + - topic_B/part_2/questions.yaml | |
39 | + | |
40 | + # -------------------------------------------------------------------------- | |
41 | + # Especificação das perguntas do teste e respectivas cotações. | |
42 | + # O teste é uma lista de perguntas, onde cada pergunta é especificada num | |
43 | + # dicionário com a referência da pergunta e a respectiva cotação. | |
54 | 44 | questions: |
55 | 45 | - ref: pergunta1 |
56 | 46 | points: 3.5 |
... | ... | @@ -60,24 +50,23 @@ |
60 | 50 | |
61 | 51 | - ref: tabela-auxiliar |
62 | 52 | |
63 | - # escolhe uma das seguintes aleatoriamente | |
64 | - - ref: [ pergunta3a, pergunta3b ] | |
53 | + # escolhe aleatoriamente uma das variantes | |
54 | + - ref: [pergunta3a, pergunta3b] | |
65 | 55 | points: 0.5 |
66 | 56 | |
67 | - # a cotação é 1.0 por defeito, caso não esteja definida | |
57 | + # a cotação é 1.0 por defeito (se omitida) | |
68 | 58 | - ref: pergunta4 |
69 | 59 | |
70 | - # ou ainda mais simples | |
60 | + # se for uma string (não dict), é interpretada como referência | |
71 | 61 | - pergunta5 |
72 | - # ---------------------------------------------------------------------------- | |
62 | + # -------------------------------------------------------------------------- | |
73 | 63 | ``` |
74 | 64 | |
75 | - A ordem das perguntas é mantida. | |
65 | + A ordem das perguntas é mantida quando apresentada para o aluno. | |
76 | 66 | |
77 | 67 | O mesmo teste pode ser realizado várias vezes em vários turnos, não é |
78 | 68 | necessário alterar nada. |
79 | 69 | |
80 | - | |
81 | 70 | # ---------------------------------------------------------------------------- |
82 | 71 | - type: information |
83 | 72 | ref: tut-questions |
... | ... | @@ -101,22 +90,22 @@ |
101 | 90 | - 3 |
102 | 91 | |
103 | 92 | #----------------------------------------------------------------------------- |
104 | - - type: info | |
93 | + - type: information | |
105 | 94 | ref: chave-unica-2 |
106 | 95 | text: | |
107 | 96 | Quando o texto da pergunta tem várias linhas, dá jeito usar o símbolo |
108 | - pipe, para indicar que tudo o que estiver indentado relativamente à | |
109 | - linha `text: |` faz parte do corpo do texto. | |
110 | - | |
97 | + `|` de pipe, para indicar que tudo o que estiver indentado faz parte do | |
98 | + texto. | |
111 | 99 | É o caso desta pergunta. |
112 | 100 | |
101 | + O texto das perguntas é escrito em `markdown`. | |
102 | + | |
113 | 103 | #----------------------------------------------------------------------------- |
114 | 104 | ``` |
115 | 105 | |
116 | 106 | As chaves são usadas para construir o teste e não se podem repetir em |
117 | 107 | ficheiros diferentes. A seguir vamos ver exemplos de cada tipo de pergunta. |
118 | 108 | |
119 | - | |
120 | 109 | # ---------------------------------------------------------------------------- |
121 | 110 | - type: radio |
122 | 111 | ref: tut-radio |
... | ... | @@ -127,82 +116,91 @@ |
127 | 116 | A utilização mais simples é a seguinte: |
128 | 117 | |
129 | 118 | ```yaml |
130 | - - type: radio | |
131 | - ref: pergunta-1 | |
132 | - title: Escolha simples, uma opção correcta. | |
133 | - text: | | |
134 | - Bla bla bla. | |
135 | - options: | |
136 | - - Opção 0 | |
137 | - - Opção 1 | |
138 | - - Opção 2 | |
139 | - - Opção 3 | |
140 | - - Opção 4 | |
119 | + - type: radio | |
120 | + ref: pergunta-1 | |
121 | + title: Escolha simples, uma opção correcta. | |
122 | + text: | | |
123 | + Bla bla bla. | |
124 | + options: | |
125 | + - Opção 0 | |
126 | + - Opção 1 | |
127 | + - Opção 2 | |
128 | + - Opção 3 | |
129 | + - Opção 4 | |
141 | 130 | ``` |
142 | 131 | |
143 | - Sem outras configurações, assume-se que a primeira opção ("Opção 0" neste | |
144 | - caso) é a resposta correcta, e todas as 5 opções são apresentadas por ordem | |
132 | + Sem outras configurações, assume-se que a primeira opção é a resposta | |
133 | + correcta ("Opção 0" neste caso) e as 5 opções são apresentadas por ordem | |
145 | 134 | aleatória. |
146 | 135 | |
147 | 136 | Para evitar que os alunos memorizem os textos das opções, podem definir-se |
148 | - várias opções correctas com escrita ligeiramente diferente, sendo | |
149 | - apresentada apenas uma delas. | |
137 | + várias opções correctas com escrita ligeiramente diferente, sendo escolhida | |
138 | + apenas uma delas para apresentação. | |
150 | 139 | Por exemplo, se as 2 primeiras opções estiverem correctas e as restantes |
151 | - erradas, e quisermos apresentar 3 opções no total com uma delas correcta | |
152 | - adiciona-se: | |
140 | + erradas, e quisermos apresentar ao aluno 3 opções no total, acrescenta-se: | |
153 | 141 | |
154 | 142 | ```yaml |
155 | - correct: [1, 1, 0, 0, 0] | |
156 | - choose: 3 | |
143 | + correct: [1, 1, 0, 0, 0] | |
144 | + choose: 3 | |
157 | 145 | ``` |
158 | 146 | |
159 | - Assim será escolhida uma opção certa e mais 2 opções erradas. | |
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 | + 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 | + | |
152 | + Por defeito, as opções são apresentadas por ordem aleatória. | |
153 | + Para manter a ordem acrescenta-se: | |
160 | 154 | |
161 | - Por defeito, as opções são sempre baralhadas. Adicionando `shuffle: False` | |
162 | - evita que o sejam. | |
155 | + ```yaml | |
156 | + shuffle: false | |
157 | + ``` | |
163 | 158 | |
164 | - Por defeito, as respostas erradas descontam 1/(n-1) do valor da pergunta, | |
165 | - onde n é o número de opções apresentadas. Para não descontar usa-se | |
166 | - `discount: False`. | |
159 | + Por defeito, as respostas erradas descontam, tendo uma cotação de -1/(n-1) | |
160 | + do valor da pergunta, onde n é o número de opções apresentadas ao aluno | |
161 | + (a ideia é o valor esperado ser zero quando as respostas são aleatórias e | |
162 | + uniformemente distribuídas). | |
163 | + Para não descontar acrescenta-se: | |
167 | 164 | |
165 | + ```yaml | |
166 | + discount: false | |
167 | + ``` | |
168 | 168 | options: |
169 | - - Opção 0 | |
170 | - - Opção 1 | |
171 | - - Opção 2 | |
172 | - - Opção 3 | |
173 | - - Opção 4 | |
169 | + - Opção 0 (certa) | |
170 | + - Opção 1 (certa) | |
171 | + - Opção 2 | |
172 | + - Opção 3 | |
173 | + - Opção 4 | |
174 | 174 | correct: [1, 1, 0, 0, 0] |
175 | 175 | choose: 3 |
176 | - shuffle: true | |
177 | 176 | solution: | |
178 | - A solução correcta é a **opção 0**. | |
177 | + A solução correcta é a **Opção 0** ou a **Opção 1**. | |
179 | 178 | |
180 | 179 | # ---------------------------------------------------------------------------- |
181 | -- type: checkbox | |
182 | - ref: tut-checkbox | |
180 | +- ref: tut-checkbox | |
181 | + type: checkbox | |
183 | 182 | title: Escolha múltipla, várias opções correctas |
184 | 183 | text: | |
185 | 184 | As perguntas de escolha múltipla permitem apresentar um conjunto de opções |
186 | 185 | podendo ser seleccionadas várias em simultaneo. |
187 | - Funcionam como múltiplas perguntas independentes com a cotação indicada em | |
188 | - `correct`. As opções não seleccionadas têm a cotação simétrica à indicada. | |
189 | - Deste modo, um aluno só deve responder se tiver confiança em pelo menos | |
190 | - metade das respostas, caso contrário arrisca-se a ter cotação negativa na | |
191 | - pergunta. | |
186 | + Funcionam como múltiplas perguntas independentes de resposta sim/não. | |
187 | + | |
188 | + Cada opção seleccionada (`sim`) recebe a cotação indicada em `correct`. | |
189 | + Cada opção não seleccionadas (`não`) tem a cotação simétrica. | |
192 | 190 | |
193 | 191 | ```yaml |
194 | - - type: checkbox | |
195 | - ref: tut-checkbox | |
196 | - title: Escolha múltipla, várias opções correctas | |
197 | - text: | | |
198 | - Bla bla bla. | |
199 | - options: | |
200 | - - Opção 0 | |
201 | - - Opção 1 | |
202 | - - Opção 2 | |
203 | - - Opção 3 | |
204 | - - Opção 4 | |
205 | - correct: [1, -1, -1, 1, -1] | |
192 | + - type: checkbox | |
193 | + ref: tut-checkbox | |
194 | + title: Escolha múltipla, várias opções correctas | |
195 | + text: | | |
196 | + Bla bla bla. | |
197 | + options: | |
198 | + - Opção 0 (certa) | |
199 | + - Opção 1 | |
200 | + - Opção 2 | |
201 | + - Opção 3 (certa) | |
202 | + - Opção 4 | |
203 | + correct: [1, -1, -1, 1, -1] | |
206 | 204 | ``` |
207 | 205 | |
208 | 206 | Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada |
... | ... | @@ -211,35 +209,37 @@ |
211 | 209 | Por exemplo se não seleccionar a opção 0, tem cotação -1, e não |
212 | 210 | seleccionando a opção 1 obtém-se +1. |
213 | 211 | |
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.)* | |
216 | + | |
214 | 217 | Cada opção pode opcionalmente ser escrita como uma afirmação e o seu |
215 | - contrário, de maneira a dar mais aleatoriedade à apresentação deste tipo de | |
216 | - perguntas. Por exemplo: | |
218 | + contrário, de maneira a aumentar a variabilidade dos textos. | |
219 | + Por exemplo: | |
217 | 220 | |
218 | 221 | ```yaml |
219 | - options: | |
220 | - - ["O céu é azul", "O céu não é azul"] | |
221 | - - ["Um triangulo tem 3 lados", "Um triangulo tem 2 lados"] | |
222 | - - O nosso planeta tem um satélite natural | |
223 | - correct: [1, 1, 1] | |
222 | + options: | |
223 | + - ["O céu é azul", "O céu não é azul"] | |
224 | + - ["Um triangulo tem 3 lados", "Um triangulo tem 2 lados"] | |
225 | + - O nosso planeta tem um satélite natural | |
226 | + correct: [1, 1, 1] | |
224 | 227 | ``` |
225 | 228 | |
226 | - Assume-se que a primeira alternativa de cada opção tem a cotação +1, | |
227 | - enquanto a segunda alternativa tem a cotação simétrica -1 (desconta se for | |
228 | - seleccionada). | |
229 | + Assume-se que a primeira alternativa de cada opção tem a cotação indicada | |
230 | + em `correct`, enquanto a segunda alternativa tem a cotação simétrica. | |
229 | 231 | |
230 | - Estão disponíveis as configurações `shuffle` e `discount`. | |
231 | - Se `discount: False` então as respostas erradas têm cotação 0 em vez do | |
232 | + Tal como nas perguntas do tipo `radio`, podem ser usadas as configurações | |
233 | + `shuffle` e `discount` com valor `false` para as desactivar. | |
234 | + Se `discount` é `false` então as respostas erradas têm cotação 0 em vez do | |
232 | 235 | simétrico. |
233 | - | |
234 | 236 | options: |
235 | - - Opção 0 (sim) | |
236 | - - Opção 1 (não) | |
237 | - - Opção 2 (não) | |
238 | - - Opção 3 (sim) | |
237 | + - ['Opção 0 (sim)', 'Opção 0 (não)'] | |
238 | + - ['Opção 1 (não)', 'Opção 1 (sim)'] | |
239 | + - Opção 2 (não) | |
240 | + - Opção 3 (sim) | |
239 | 241 | correct: [1, -1, -1, 1] |
240 | - choose: 3 | |
241 | - shuffle: true | |
242 | - | |
242 | + shuffle: false | |
243 | 243 | |
244 | 244 | # ---------------------------------------------------------------------------- |
245 | 245 | - type: text |
... | ... | @@ -247,41 +247,51 @@ |
247 | 247 | title: Resposta de texto em linha |
248 | 248 | text: | |
249 | 249 | Este tipo de perguntas permite uma resposta numa linha de texto. A resposta |
250 | - está correcta se coincidir com alguma das respostas admissíveis. | |
250 | + está correcta se coincidir exactamente com alguma das respostas admissíveis. | |
251 | 251 | |
252 | 252 | ```yaml |
253 | - - type: text | |
254 | - ref: tut-text | |
255 | - title: Resposta de texto em linha | |
256 | - text: | | |
257 | - Bla bla bla | |
258 | - correct: ['azul', 'Azul', 'AZUL'] | |
253 | + - type: text | |
254 | + ref: tut-text | |
255 | + title: Resposta de texto em linha | |
256 | + text: | | |
257 | + De que cor é o céu? | |
258 | + | |
259 | + Escreva a resposta em português. | |
260 | + correct: ['azul', 'Azul', 'AZUL'] | |
259 | 261 | ``` |
260 | 262 | |
261 | - Neste exemplo a resposta correcta é `azul`, `Azul` ou `AZUL`. | |
263 | + Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`. | |
262 | 264 | correct: ['azul', 'Azul', 'AZUL'] |
263 | 265 | |
264 | - | |
265 | 266 | # --------------------------------------------------------------------------- |
266 | 267 | - type: text-regex |
267 | 268 | ref: tut-text-regex |
268 | - title: Resposta de texto em linha | |
269 | + title: Resposta de texto em linha, expressão regular | |
269 | 270 | text: | |
270 | - Este tipo de pergunta é semelhante à linha de texto da pergunta anterior. A | |
271 | - única diferença é que esta é validada por uma expressão regular. | |
271 | + Este tipo de pergunta é semelhante à linha de texto da pergunta anterior. | |
272 | + A única diferença é que esta é validada por uma expressão regular. | |
272 | 273 | |
273 | 274 | ```yaml |
274 | - - type: text-regex | |
275 | - ref: tut-text-regex | |
276 | - title: Resposta de texto em linha | |
277 | - text: | | |
278 | - Bla bla bla | |
279 | - correct: !regex '(VERDE|[Vv]erde)' | |
275 | + - type: text-regex | |
276 | + ref: tut-text-regex | |
277 | + title: Resposta de texto em linha | |
278 | + text: | | |
279 | + Bla bla bla | |
280 | + correct: '(VERDE|[Vv]erde)' | |
280 | 281 | ``` |
281 | 282 | |
282 | 283 | Neste exemplo a expressão regular é `(VERDE|[Vv]erde)`. |
283 | - correct: '(VERDE|[Vv]erde)' | |
284 | 284 | |
285 | + --- | |
286 | + | |
287 | + **Atenção:** A expressão regular deve seguir as convenções da suportadas em | |
288 | + python (ver | |
289 | + [Regular expression operations](https://docs.python.org/3/library/re.html)). | |
290 | + Em particular, a expressão regular acima também aceita a resposta | |
291 | + `verde, azul`. | |
292 | + Possivelmente devia marcar-se o final com o cifrão `(VERDE|[Vv]erde)$`. | |
293 | + | |
294 | + correct: '(VERDE|[Vv]erde)' | |
285 | 295 | |
286 | 296 | # --------------------------------------------------------------------------- |
287 | 297 | - type: numeric-interval |
... | ... | @@ -289,23 +299,27 @@ |
289 | 299 | title: Resposta numérica em linha de texto |
290 | 300 | text: | |
291 | 301 | Este tipo de perguntas esperam uma resposta numérica (vírgula flutuante). |
292 | - O resultado é considerado correcto se estiver dentro do intervalo (fechado) | |
302 | + O resultado é considerado correcto se estiver dentro do intervalo fechado | |
293 | 303 | indicado. |
294 | 304 | |
295 | 305 | ```yaml |
296 | - - type: numeric-interval | |
297 | - ref: tut-numeric-interval | |
298 | - title: Resposta numérica em linha de texto | |
299 | - text: | | |
300 | - Bla bla bla | |
301 | - correct: [3.14, 3.15] | |
306 | + - type: numeric-interval | |
307 | + ref: tut-numeric-interval | |
308 | + title: Resposta numérica em linha de texto | |
309 | + text: | | |
310 | + Escreva o número $\pi$ com pelo menos duas casa decimais. | |
311 | + correct: [3.14, 3.15] | |
302 | 312 | ``` |
303 | 313 | |
304 | 314 | Neste exemplo o intervalo de respostas correctas é [3.14, 3.15]. |
315 | + | |
316 | + **Atenção:** as respostas têm de usar o ponto como separador decimal. | |
317 | + Em geral são aceites números inteiros, como `123`, | |
318 | + ou em vírgula flutuante, como em `0.23`, `1e-3`. | |
305 | 319 | correct: [3.14, 3.15] |
306 | 320 | solution: | |
307 | - Um exemplo de uma resposta correcta é o número $\pi\approx 3.14159265359$. | |
308 | - | |
321 | + Sabems que $\pi\approx 3.14159265359$. | |
322 | + Portanto, um exemplo de uma resposta correcta é `3.1416`. | |
309 | 323 | |
310 | 324 | # --------------------------------------------------------------------------- |
311 | 325 | - type: textarea |
... | ... | @@ -313,47 +327,61 @@ |
313 | 327 | title: Resposta em múltiplas linhas de texto |
314 | 328 | text: | |
315 | 329 | Este tipo de perguntas permitem respostas em múltiplas linhas de texto, que |
316 | - podem ser úteis por exemplo para validar código. | |
317 | - A resposta é enviada para ser avaliada por um programa externo (programa | |
318 | - executável). | |
319 | - O programa externo, recebe a resposta via stdin e devolve a classificação | |
320 | - via stdout. Exemplo: | |
330 | + podem ser úteis por exemplo para introduzir código. | |
331 | + | |
332 | + A resposta é enviada para um programa externo para ser avaliada. | |
333 | + O programa externo é um programa qualquer executável pelo sistema. | |
334 | + Este recebe a resposta submetida pelo aluno via `stdin` e devolve a | |
335 | + classificação via `stdout`. | |
336 | + Exemplo: | |
321 | 337 | |
322 | 338 | ```yaml |
323 | - - type: textarea | |
324 | - ref: tut-textarea | |
325 | - title: Resposta em múltiplas linhas de texto | |
326 | - text: | | |
327 | - Bla bla bla | |
328 | - correct: correct/correct-question.py | |
329 | - lines: 3 | |
330 | - timeout: 5 | |
339 | + - type: textarea | |
340 | + ref: tut-textarea | |
341 | + title: Resposta em múltiplas linhas de texto | |
342 | + text: | | |
343 | + Bla bla bla | |
344 | + correct: correct/correct-question.py # programa a executar | |
345 | + timeout: 5 | |
331 | 346 | ``` |
332 | 347 | |
333 | 348 | Neste exemplo, o programa de avaliação é um script python que verifica se a |
334 | - resposta contém as três palavras red, green e blue, e calcula uma nota de | |
335 | - 0.0 a 1.0. | |
336 | - O programa externo pode ser escrito em qualquer linguagem e a interacção | |
337 | - com o servidor faz-se via stdin/stdout. | |
338 | - Se o programa externo demorar mais do que o `timout` indicado, é | |
339 | - automaticamente cancelado e é atribuída a classificação de 0.0 valores. | |
340 | - `lines: 3` é a dimensão inicial da caixa de texto (pode depois ser | |
341 | - redimensionada pelo aluno). | |
342 | - | |
343 | - O programa externo deve atribuir uma classificação entre 0.0 e 1.0. Pode | |
344 | - simplesmente fazer print da classificação como um número, ou opcionalmente escrever em formato yaml eventualmente com um comentário. Exemplo: | |
349 | + resposta contém as três palavras red, green e blue, e calcula uma nota no | |
350 | + intervalo 0.0 a 1.0. | |
351 | + O programa externo é um programa executável no sistema, escrito em | |
352 | + qualquer linguagem de programação. A interacção com o servidor faz-se | |
353 | + sempre via stdin/stdout. | |
354 | + | |
355 | + Se o programa externo exceder o `timeout` indicado (em segundos), | |
356 | + é automaticamente cancelado e é atribuída a classificação de 0.0 valores. | |
357 | + | |
358 | + Após terminar a correcção, o programa externo deve enviar a classificação | |
359 | + para o stdout. | |
360 | + Pode simplesmente fazer `print` da classificação como um número em vírgula | |
361 | + flutuante, por exemplo | |
345 | 362 | |
346 | 363 | ```yaml |
347 | - grade: 0.5 | |
348 | - comments: A resposta correcta é "red green blue". | |
364 | + 0.75 | |
365 | + ``` | |
366 | + | |
367 | + ou opcionalmente escrever em formato yaml, eventualmente com um comentário | |
368 | + que será arquivado com o teste. | |
369 | + Exemplo: | |
370 | + | |
371 | + ```yaml | |
372 | + grade: 0.5 | |
373 | + comments: | | |
374 | + Esqueceu-se de algumas cores. | |
375 | + A resposta correcta era `red green blue`. | |
349 | 376 | ``` |
350 | 377 | |
351 | 378 | O comentário é mostrado na revisão de prova. |
379 | + answer: | | |
380 | + Esta caixa aumenta de tamanho automaticamente e | |
381 | + pode estar previamente preenchida (use answer: texto). | |
352 | 382 | correct: correct/correct-question.py |
353 | - lines: 3 | |
354 | 383 | timeout: 5 |
355 | 384 | |
356 | - | |
357 | 385 | # --------------------------------------------------------------------------- |
358 | 386 | - type: information |
359 | 387 | ref: tut-information |
... | ... | @@ -361,37 +389,39 @@ |
361 | 389 | text: | |
362 | 390 | As perguntas deste tipo não contam para avaliação. O objectivo é fornecer |
363 | 391 | instruções para os alunos, por exemplo tabelas para consulta, fórmulas, etc. |
364 | - Nesta como em todos os tipos de perguntas pode escrever-se fórmulas em | |
365 | - LaTeX. Exemplo: | |
366 | - | |
367 | - ```yaml | |
368 | - - type: information | |
369 | - ref: tut-information | |
370 | - title: Texto informativo | |
371 | - text: | | |
372 | - A distribuição gaussiana $\mathcal{N}(x\mid\mu,\sigma^2)$ é | |
373 | - definida por | |
374 | - | |
375 | - $$ | |
376 | - p(x) = \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\tfrac{1}{2}\tfrac{(x-\mu)^2}{\sigma^2}}. | |
377 | - $$ | |
378 | - ``` | |
392 | + Nesta, tal como em todos os tipos de perguntas podem escrever-se fórmulas | |
393 | + em LaTeX. Exemplo: | |
379 | 394 | |
380 | - Produz: | |
381 | - | |
382 | - A distribuição gaussiana $\mathcal{N}(x\mid\mu,\sigma^2)$ é definida por | |
395 | + A distribuição gaussiana $\mathcal{N}(x\mid\mu,\sigma^2)$ é | |
396 | + definida pela função densidade de probabilidade | |
383 | 397 | |
384 | 398 | $$ |
385 | - p(x) = \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\tfrac{1}{2}\tfrac{(x-\mu)^2}{\sigma^2}}. | |
399 | + p(x) = \frac{1}{\sqrt{2\pi\sigma^2}} | |
400 | + \exp\Big({-\frac{(x-\mu)^2}{2\sigma^2}}\Big). | |
386 | 401 | $$ |
387 | 402 | |
403 | + --- | |
404 | + | |
405 | + ```yaml | |
406 | + - type: information | |
407 | + ref: tut-information | |
408 | + title: Texto informativo | |
409 | + text: | | |
410 | + A distribuição gaussiana $\mathcal{N}(x\mid\mu,\sigma^2)$ é | |
411 | + definida pela função densidade de probabilidade | |
412 | + | |
413 | + $$ | |
414 | + p(x) = \frac{1}{\sqrt{2\pi\sigma^2}} | |
415 | + \exp\Big({-\frac{(x-\mu)^2}{2\sigma^2}}\Big). | |
416 | + $$ | |
417 | + ``` | |
388 | 418 | |
389 | 419 | # --------------------------------------------------------------------------- |
390 | 420 | - type: success |
391 | 421 | ref: tut-success |
392 | 422 | title: Texto informativo (sucesso) |
393 | 423 | text: | |
394 | - Também não conta para avaliação. | |
424 | + Também não conta para avaliação. É apenas o aspecto gráfico que muda. | |
395 | 425 | |
396 | 426 | Além das fórmulas LaTeX, também se pode escrever troços de código: |
397 | 427 | |
... | ... | @@ -402,16 +432,16 @@ |
402 | 432 | } |
403 | 433 | ``` |
404 | 434 | |
405 | - Faz-se assim: | |
435 | + --- | |
406 | 436 | |
407 | 437 | - type: success |
408 | 438 | ref: tut-success |
409 | 439 | title: Texto informativo (sucesso) |
410 | 440 | text: | |
411 | 441 | Também não conta para avaliação. |
442 | + É apenas o aspecto gráfico que muda. | |
412 | 443 | |
413 | - Já vimos como se introduzem fórmulas LaTeX, também se pode escrever | |
414 | - troços de código: | |
444 | + Além das fórmulas LaTeX, também se pode escrever troços de código: | |
415 | 445 | |
416 | 446 | ```C |
417 | 447 | int main() { |
... | ... | @@ -420,7 +450,6 @@ |
420 | 450 | } |
421 | 451 | ``` |
422 | 452 | |
423 | - | |
424 | 453 | # --------------------------------------------------------------------------- |
425 | 454 | - type: warning |
426 | 455 | ref: tut-warning |
... | ... | @@ -430,13 +459,15 @@ |
430 | 459 | |
431 | 460 | Neste exemplo mostramos como se pode construir uma tabela como a seguinte: |
432 | 461 | |
433 | - Left | Center | Right | |
434 | - -----------------|:-------------:|----------: | |
435 | - $\sin(x^2)$ | *hello* | $1600.00 | |
436 | - $\frac{1}{2\pi}$ | **world** | $12.50 | |
437 | - $\sqrt{\pi}$ | `code` | $1.99 | |
462 | + Left | Center | Right | |
463 | + -----------------|:----------------:|----------: | |
464 | + *hello* | $\sin(x^2)$ | $1600.00 | |
465 | + **world** | $\frac{1}{2\pi}$ | $12.50 | |
466 | + `code` | $\sqrt{\pi}$ | $1.99 | |
438 | 467 | |
439 | - As tabelas podem conter Markdown e LaTeX. Faz-se assim: | |
468 | + As tabelas podem conter Markdown e LaTeX. | |
469 | + | |
470 | + --- | |
440 | 471 | |
441 | 472 | ```yaml |
442 | 473 | - type: warning |
... | ... | @@ -445,23 +476,19 @@ |
445 | 476 | text: | |
446 | 477 | Bla bla bla |
447 | 478 | |
448 | - Left | Center | Right | |
449 | - -----------------|:-------------:|----------: | |
450 | - $\sin(x^2)$ | *hello* | $1600.00 | |
451 | - $\frac{1}{2\pi}$ | **world** | $12.50 | |
452 | - $\sqrt{\pi}$ | `code` | $1.99 | |
479 | + Left | Center | Right | |
480 | + -----------------|:----------------:|----------: | |
481 | + *hello* | $\sin(x^2)$ | $1600.00 | |
482 | + **world** | $\frac{1}{2\pi}$ | $12.50 | |
483 | + `code` | $\sqrt{\pi}$ | $1.99 | |
453 | 484 | ``` |
454 | 485 | |
455 | - A linha de separação entre o cabeçalho e o corpo da tabela indica o | |
456 | - alinhamento da coluna com os sinais de dois-pontos. | |
457 | - | |
458 | - | |
459 | 486 | # ---------------------------------------------------------------------------- |
460 | 487 | - type: alert |
461 | 488 | ref: tut-alert |
462 | 489 | title: Texto informativo (perigo) |
463 | 490 | text: | |
464 | - Texto importante. Não conta para avaliação. | |
491 | + Não conta para avaliação. Texto importante. | |
465 | 492 | |
466 | 493 |  |
467 | 494 | |
... | ... | @@ -473,5 +500,8 @@ |
473 | 500 | - Imagens centradas com título: ``. |
474 | 501 | O título aprece por baixo da imagem. O título pode ser uma string vazia. |
475 | 502 | |
503 | +# ---------------------------------------------------------------------------- | |
504 | +- type: information | |
505 | + text: This question is not included in the test and will not show up. | |
476 | 506 | |
477 | 507 | # ---------------------------------------------------------------------------- | ... | ... |
demo/tutorial.yaml
... | ... | @@ -1,57 +0,0 @@ |
1 | ---- | |
2 | -# ============================================================================ | |
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'" | |
6 | -ref: tutorial | |
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: 120 | |
13 | - | |
14 | -# 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 | |
17 | - | |
18 | -# Generate a file for each test done by a student. | |
19 | -# It includes the questions, answers and grades. | |
20 | -answers_dir: ans | |
21 | - | |
22 | -# (optional, default: False) Show points for each question, scale 0-20. | |
23 | -show_points: true | |
24 | -# scale_points: true | |
25 | -# scale_max: 20 | |
26 | - | |
27 | -# ---------------------------------------------------------------------------- | |
28 | -# Base path applied to the questions files and all the scripts | |
29 | -# including question generators and correctors. | |
30 | -# Either absolute path or relative to current directory can be used. | |
31 | -questions_dir: . | |
32 | - | |
33 | -# (optional) List of files containing questions in yaml format. | |
34 | -# Selected questions will be obtained from these files. | |
35 | -# If undefined, all yaml files in questions_dir are loaded (not recommended). | |
36 | -files: | |
37 | - - questions/questions-tutorial.yaml | |
38 | - | |
39 | -# This is the list of questions that will make up the test. | |
40 | -# The order is preserved. | |
41 | -# There are several ways to define each question (explained below). | |
42 | -questions: | |
43 | - - tut-test | |
44 | - - tut-questions | |
45 | - | |
46 | - - tut-radio | |
47 | - - tut-checkbox | |
48 | - - tut-text | |
49 | - - tut-text-regex | |
50 | - - tut-numeric-interval | |
51 | - - ref: tut-textarea | |
52 | - points: 2.0 | |
53 | - | |
54 | - - tut-information | |
55 | - - tut-success | |
56 | - - tut-warning | |
57 | - - tut-alert |
perguntations/app.py
... | ... | @@ -71,6 +71,7 @@ class App(object): |
71 | 71 | testconf.update(conf) # configuration overrides from command line |
72 | 72 | |
73 | 73 | # start test factory |
74 | + logger.info(f'Creating test factory.') | |
74 | 75 | try: |
75 | 76 | self.testfactory = TestFactory(testconf) |
76 | 77 | except TestFactoryException: |
... | ... | @@ -147,7 +148,7 @@ class App(object): |
147 | 148 | student_id = self.online[uid]['student'] # {number, name} |
148 | 149 | test = await self.testfactory.generate(student_id) |
149 | 150 | self.online[uid]['test'] = test |
150 | - logger.debug(f'Student {uid}: test is ready.') | |
151 | + logger.info(f'Student {uid}: test is ready.') | |
151 | 152 | return self.online[uid]['test'] |
152 | 153 | else: |
153 | 154 | # this implies an error in the code. should never be here! |
... | ... | @@ -158,19 +159,24 @@ class App(object): |
158 | 159 | # for example: {0:'hello', 1:[1,2]} |
159 | 160 | async def correct_test(self, uid, ans): |
160 | 161 | t = self.online[uid]['test'] |
162 | + | |
163 | + # --- submit answers and correct test | |
161 | 164 | t.update_answers(ans) |
165 | + logger.info(f'Student {uid}: {len(ans)} answers submitted.') | |
166 | + | |
162 | 167 | grade = await t.correct() |
168 | + logger.info(f'Student {uid}: grade = {grade} points.') | |
163 | 169 | |
164 | - # save test in JSON format | |
165 | - fields = (t['student']['number'], t['ref'], str(t['finish_time'])) | |
170 | + # --- save test in JSON format | |
171 | + fields = (uid, t['ref'], str(t['finish_time'])) | |
166 | 172 | fname = ' -- '.join(fields) + '.json' |
167 | 173 | fpath = path.join(t['answers_dir'], fname) |
168 | 174 | with open(path.expanduser(fpath), 'w') as f: |
169 | - # default=str required for datetime objects: | |
175 | + # default=str required for datetime objects | |
170 | 176 | json.dump(t, f, indent=2, default=str) |
171 | - logger.info(f'Student {t["student"]["number"]}: saved JSON file.') | |
177 | + logger.info(f'Student {uid}: saved JSON.') | |
172 | 178 | |
173 | - # insert test and questions into database | |
179 | + # --- insert test and questions into database | |
174 | 180 | with self.db_session() as s: |
175 | 181 | s.add(Test( |
176 | 182 | ref=t['ref'], |
... | ... | @@ -179,7 +185,7 @@ class App(object): |
179 | 185 | starttime=str(t['start_time']), |
180 | 186 | finishtime=str(t['finish_time']), |
181 | 187 | filename=fpath, |
182 | - student_id=t['student']['number'], | |
188 | + student_id=uid, | |
183 | 189 | state=t['state'], |
184 | 190 | comment='')) |
185 | 191 | s.add_all([Question( |
... | ... | @@ -187,10 +193,10 @@ class App(object): |
187 | 193 | grade=q['grade'], |
188 | 194 | starttime=str(t['start_time']), |
189 | 195 | finishtime=str(t['finish_time']), |
190 | - student_id=t['student']['number'], | |
196 | + student_id=uid, | |
191 | 197 | test_id=t['ref']) for q in t['questions'] if 'grade' in q]) |
192 | 198 | |
193 | - logger.info(f'Student {uid}: finished test, grade = {grade}.') | |
199 | + logger.info(f'Student {uid}: database updated.') | |
194 | 200 | return grade |
195 | 201 | |
196 | 202 | # ----------------------------------------------------------------------- | ... | ... |
perguntations/factory.py
... | ... | @@ -1,173 +0,0 @@ |
1 | -# We start with an empty QuestionFactory() that will be populated with | |
2 | -# question generators that we can load from YAML files. | |
3 | -# To generate an instance of a question we use the method generate(ref) where | |
4 | -# the argument is the reference of the question we wish to produce. | |
5 | -# | |
6 | -# Example: | |
7 | -# | |
8 | -# # read everything from question files | |
9 | -# factory = QuestionFactory() | |
10 | -# factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to') | |
11 | -# | |
12 | -# question = factory.generate('some_ref') | |
13 | -# | |
14 | -# # experiment answering one question and correct it | |
15 | -# question['answer'] = 42 # insert answer | |
16 | -# grade = question.correct() # correct answer | |
17 | - | |
18 | -# An instance of an actual question is an object that inherits from Question() | |
19 | -# | |
20 | -# Question - base class inherited by other classes | |
21 | -# QuestionInformation - not a question, just a box with content | |
22 | -# QuestionRadio - single choice from a list of options | |
23 | -# QuestionCheckbox - multiple choice, equivalent to multiple true/false | |
24 | -# QuestionText - line of text compared to a list of acceptable answers | |
25 | -# QuestionTextRegex - line of text matched against a regular expression | |
26 | -# QuestionTextArea - corrected by an external program | |
27 | -# QuestionNumericInterval - line of text parsed as a float | |
28 | - | |
29 | -# python standard library | |
30 | -from os import path | |
31 | -import logging | |
32 | - | |
33 | -# this project | |
34 | -from perguntations.tools import load_yaml, run_script | |
35 | -from perguntations.questions import (QuestionRadio, QuestionCheckbox, | |
36 | - QuestionText, QuestionTextRegex, | |
37 | - QuestionNumericInterval, QuestionTextArea, | |
38 | - QuestionInformation) | |
39 | - | |
40 | -# setup logger for this module | |
41 | -logger = logging.getLogger(__name__) | |
42 | - | |
43 | - | |
44 | -# ============================================================================ | |
45 | -class QuestionFactoryException(Exception): | |
46 | - pass | |
47 | - | |
48 | - | |
49 | -# ============================================================================ | |
50 | -# This class contains a pool of questions generators from which particular | |
51 | -# Question() instances are generated using QuestionsFactory.generate(ref). | |
52 | -# ============================================================================ | |
53 | -class QuestionFactory(dict): | |
54 | - _types = { | |
55 | - 'radio': QuestionRadio, | |
56 | - 'checkbox': QuestionCheckbox, | |
57 | - 'text': QuestionText, | |
58 | - 'text-regex': QuestionTextRegex, | |
59 | - 'numeric-interval': QuestionNumericInterval, | |
60 | - 'textarea': QuestionTextArea, | |
61 | - # -- informative panels -- | |
62 | - 'information': QuestionInformation, | |
63 | - 'success': QuestionInformation, | |
64 | - 'warning': QuestionInformation, | |
65 | - 'alert': QuestionInformation | |
66 | - } | |
67 | - | |
68 | - # ------------------------------------------------------------------------ | |
69 | - def __init__(self): | |
70 | - super().__init__() | |
71 | - | |
72 | - # ------------------------------------------------------------------------ | |
73 | - # Add single question provided in a dictionary. | |
74 | - # After this, each question will have at least 'ref' and 'type' keys. | |
75 | - # ------------------------------------------------------------------------ | |
76 | - def add_question(self, question): | |
77 | - q = question | |
78 | - | |
79 | - # if missing defaults to ref='/path/file.yaml:3' | |
80 | - q.setdefault('ref', f'{q["filename"]}:{q["index"]}') | |
81 | - | |
82 | - if q['ref'] in self: | |
83 | - logger.error(f'Duplicate reference "{q["ref"]}".') | |
84 | - | |
85 | - q.setdefault('type', 'information') | |
86 | - | |
87 | - self[q['ref']] = q | |
88 | - logger.debug(f'Added question "{q["ref"]}" to the pool.') | |
89 | - | |
90 | - # ------------------------------------------------------------------------ | |
91 | - # load single YAML questions file | |
92 | - # ------------------------------------------------------------------------ | |
93 | - def load_file(self, pathfile, questions_dir=''): | |
94 | - # questions_dir is a base directory | |
95 | - # pathfile is a path of a file under the questions_dir | |
96 | - # For example, if | |
97 | - # pathfile = 'math/questions.yaml' | |
98 | - # questions_dir = '/home/john/questions' | |
99 | - # then the complete path is | |
100 | - # fullpath = '/home/john/questions/math/questions.yaml' | |
101 | - fullpath = path.normpath(path.join(questions_dir, pathfile)) | |
102 | - (dirname, filename) = path.split(fullpath) | |
103 | - | |
104 | - questions = load_yaml(fullpath, default=[]) | |
105 | - | |
106 | - for i, q in enumerate(questions): | |
107 | - try: | |
108 | - q.update({ | |
109 | - 'filename': filename, | |
110 | - 'path': dirname, | |
111 | - 'index': i # position in the file, 0 based | |
112 | - }) | |
113 | - except AttributeError: | |
114 | - logger.error(f'Question {pathfile}:{i} not a dictionary!') | |
115 | - else: | |
116 | - self.add_question(q) | |
117 | - | |
118 | - logger.info(f'{len(questions):>4} from "{pathfile}"') | |
119 | - | |
120 | - # ------------------------------------------------------------------------ | |
121 | - # load multiple YAML question files | |
122 | - # ------------------------------------------------------------------------ | |
123 | - def load_files(self, files, questions_dir): | |
124 | - logger.info(f'Loading questions from files in "{questions_dir}":') | |
125 | - for filename in files: | |
126 | - self.load_file(filename, questions_dir) | |
127 | - | |
128 | - # ------------------------------------------------------------------------ | |
129 | - # Given a ref returns an instance of a descendent of Question(), | |
130 | - # i.e. a question object (radio, checkbox, ...). | |
131 | - # ------------------------------------------------------------------------ | |
132 | - def generate(self, ref): | |
133 | - | |
134 | - # Shallow copy so that script generated questions will not replace | |
135 | - # the original generators | |
136 | - try: | |
137 | - q = self[ref].copy() | |
138 | - except KeyError: # FIXME exception type? | |
139 | - logger.error(f'Can\'t find question "{ref}".') | |
140 | - raise QuestionFactoryException() | |
141 | - | |
142 | - # If question is of generator type, an external program will be run | |
143 | - # which will print a valid question in yaml format to stdout. This | |
144 | - # output is then converted to a dictionary and `q` becomes that dict. | |
145 | - if q['type'] == 'generator': | |
146 | - logger.debug(f'Generating "{ref}" from {q["script"]}') | |
147 | - q.setdefault('args', []) # optional arguments | |
148 | - q.setdefault('stdin', '') # FIXME necessary? | |
149 | - script = path.join(q['path'], q['script']) | |
150 | - out = run_script(script=script, args=q['args'], stdin=q['stdin']) | |
151 | - try: | |
152 | - q.update(out) | |
153 | - except Exception: | |
154 | - q.update({ | |
155 | - 'type': 'alert', | |
156 | - 'title': 'Erro interno', | |
157 | - 'text': 'Ocorreu um erro a gerar esta pergunta.' | |
158 | - }) | |
159 | - | |
160 | - # The generator was replaced by a question but not yet instantiated | |
161 | - | |
162 | - # Finally we create an instance of Question() | |
163 | - try: | |
164 | - qinstance = self._types[q['type']](q) # instance of correct class | |
165 | - except KeyError: | |
166 | - logger.error(f'Unknown type {q["type"]} in {q["filename"]}:{ref}.') | |
167 | - raise | |
168 | - except Exception: | |
169 | - logger.error(f'Failed to create question {q["filename"]}:{ref}.') | |
170 | - raise | |
171 | - else: | |
172 | - logger.debug(f'Generated question "{ref}".') | |
173 | - return qinstance |
perguntations/models.py
... | ... | @@ -34,7 +34,7 @@ class Test(Base): |
34 | 34 | ref = Column(String) |
35 | 35 | title = Column(String) # FIXME depends on ref and should come from another table... |
36 | 36 | grade = Column(Float) |
37 | - state = Column(String) # ONGOING, FINISHED, QUIT, NULL | |
37 | + state = Column(String) # ACTIVE, FINISHED, QUIT, NULL | |
38 | 38 | comment = Column(String) |
39 | 39 | starttime = Column(String) |
40 | 40 | finishtime = Column(String) | ... | ... |
perguntations/questions.py
... | ... | @@ -5,10 +5,10 @@ import re |
5 | 5 | from os import path |
6 | 6 | import logging |
7 | 7 | from typing import Any, Dict, NewType |
8 | -# import uuid | |
8 | +import uuid | |
9 | 9 | |
10 | 10 | # this project |
11 | -from perguntations.tools import run_script, run_script_async | |
11 | +from .tools import run_script, run_script_async | |
12 | 12 | |
13 | 13 | # setup logger for this module |
14 | 14 | logger = logging.getLogger(__name__) |
... | ... | @@ -71,17 +71,18 @@ class QuestionRadio(Question): |
71 | 71 | ''' |
72 | 72 | |
73 | 73 | # ------------------------------------------------------------------------ |
74 | + # FIXME marking all options right breaks | |
74 | 75 | def __init__(self, q: QDict) -> None: |
75 | 76 | super().__init__(q) |
76 | 77 | |
77 | 78 | n = len(self['options']) |
78 | 79 | |
79 | - # set defaults if missing | |
80 | 80 | self.set_defaults(QDict({ |
81 | 81 | 'text': '', |
82 | 82 | 'correct': 0, |
83 | 83 | 'shuffle': True, |
84 | 84 | 'discount': True, |
85 | + 'max_tries': (n + 3) // 4 # 1 try for each 4 options | |
85 | 86 | })) |
86 | 87 | |
87 | 88 | # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] |
... | ... | @@ -97,11 +98,11 @@ class QuestionRadio(Question): |
97 | 98 | raise QuestionException(msg) |
98 | 99 | |
99 | 100 | if self['shuffle']: |
100 | - # separate right from wrong options | |
101 | + # lists with indices of right and wrong options | |
101 | 102 | right = [i for i in range(n) if self['correct'][i] >= 1] |
102 | 103 | wrong = [i for i in range(n) if self['correct'][i] < 1] |
103 | 104 | |
104 | - self.set_defaults({'choose': 1+len(wrong)}) | |
105 | + self.set_defaults(QDict({'choose': 1+len(wrong)})) | |
105 | 106 | |
106 | 107 | # try to choose 1 correct option |
107 | 108 | if right: |
... | ... | @@ -124,7 +125,7 @@ class QuestionRadio(Question): |
124 | 125 | self['correct'] = [float(correct[i]) for i in perm] |
125 | 126 | |
126 | 127 | # ------------------------------------------------------------------------ |
127 | - # can return negative values for wrong answers | |
128 | + # can assign negative grades for wrong answers | |
128 | 129 | def correct(self) -> None: |
129 | 130 | super().correct() |
130 | 131 | |
... | ... | @@ -157,13 +158,14 @@ class QuestionCheckbox(Question): |
157 | 158 | n = len(self['options']) |
158 | 159 | |
159 | 160 | # set defaults if missing |
160 | - self.set_defaults({ | |
161 | + self.set_defaults(QDict({ | |
161 | 162 | 'text': '', |
162 | - 'correct': [1.0] * n, # Using 0.0 breaks the (right, wrong) opts | |
163 | + 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options | |
163 | 164 | 'shuffle': True, |
164 | 165 | 'discount': True, |
165 | - 'choose': n, # number of options | |
166 | - }) | |
166 | + 'choose': n, # number of options | |
167 | + 'max_tries': max(1, min(n - 1, 3)) | |
168 | + })) | |
167 | 169 | |
168 | 170 | if len(self['correct']) != n: |
169 | 171 | msg = (f'Options and correct size mismatch in ' |
... | ... | @@ -172,23 +174,26 @@ class QuestionCheckbox(Question): |
172 | 174 | raise QuestionException(msg) |
173 | 175 | |
174 | 176 | # if an option is a list of (right, wrong), pick one |
175 | - # FIXME it's possible that all options are chosen wrong | |
176 | 177 | options = [] |
177 | 178 | correct = [] |
178 | 179 | for o, c in zip(self['options'], self['correct']): |
179 | 180 | if isinstance(o, list): |
180 | 181 | r = random.randint(0, 1) |
181 | 182 | o = o[r] |
182 | - c = c if r == 0 else -c | |
183 | + if r == 1: | |
184 | + c = -c | |
183 | 185 | options.append(str(o)) |
184 | 186 | correct.append(float(c)) |
185 | 187 | |
186 | 188 | # generate random permutation, e.g. [2,1,4,0,3] |
187 | 189 | # and apply to `options` and `correct` |
188 | 190 | if self['shuffle']: |
189 | - perm = random.sample(range(n), self['choose']) | |
191 | + perm = random.sample(range(n), k=self['choose']) | |
190 | 192 | self['options'] = [options[i] for i in perm] |
191 | 193 | self['correct'] = [correct[i] for i in perm] |
194 | + else: | |
195 | + self['options'] = options[:self['choose']] | |
196 | + self['correct'] = correct[:self['choose']] | |
192 | 197 | |
193 | 198 | # ------------------------------------------------------------------------ |
194 | 199 | # can return negative values for wrong answers |
... | ... | @@ -213,7 +218,7 @@ class QuestionCheckbox(Question): |
213 | 218 | self['grade'] = x / sum_abs |
214 | 219 | |
215 | 220 | |
216 | -# ============================================================================ | |
221 | +# =========================================================================== | |
217 | 222 | class QuestionText(Question): |
218 | 223 | '''An instance of QuestionText will always have the keys: |
219 | 224 | type (str) |
... | ... | @@ -226,10 +231,10 @@ class QuestionText(Question): |
226 | 231 | def __init__(self, q: QDict) -> None: |
227 | 232 | super().__init__(q) |
228 | 233 | |
229 | - self.set_defaults({ | |
234 | + self.set_defaults(QDict({ | |
230 | 235 | 'text': '', |
231 | 236 | 'correct': [], |
232 | - }) | |
237 | + })) | |
233 | 238 | |
234 | 239 | # make sure its always a list of possible correct answers |
235 | 240 | if not isinstance(self['correct'], list): |
... | ... | @@ -239,7 +244,6 @@ class QuestionText(Question): |
239 | 244 | self['correct'] = [str(a) for a in self['correct']] |
240 | 245 | |
241 | 246 | # ------------------------------------------------------------------------ |
242 | - # can return negative values for wrong answers | |
243 | 247 | def correct(self) -> None: |
244 | 248 | super().correct() |
245 | 249 | |
... | ... | @@ -260,13 +264,12 @@ class QuestionTextRegex(Question): |
260 | 264 | def __init__(self, q: QDict) -> None: |
261 | 265 | super().__init__(q) |
262 | 266 | |
263 | - self.set_defaults({ | |
267 | + self.set_defaults(QDict({ | |
264 | 268 | 'text': '', |
265 | 269 | 'correct': '$.^', # will always return false |
266 | - }) | |
270 | + })) | |
267 | 271 | |
268 | 272 | # ------------------------------------------------------------------------ |
269 | - # can return negative values for wrong answers | |
270 | 273 | def correct(self) -> None: |
271 | 274 | super().correct() |
272 | 275 | if self['answer'] is not None: |
... | ... | @@ -298,7 +301,6 @@ class QuestionNumericInterval(Question): |
298 | 301 | })) |
299 | 302 | |
300 | 303 | # ------------------------------------------------------------------------ |
301 | - # can return negative values for wrong answers | |
302 | 304 | def correct(self) -> None: |
303 | 305 | super().correct() |
304 | 306 | if self['answer'] is not None: |
... | ... | @@ -307,7 +309,8 @@ class QuestionNumericInterval(Question): |
307 | 309 | try: # replace , by . and convert to float |
308 | 310 | answer = float(self['answer'].replace(',', '.', 1)) |
309 | 311 | except ValueError: |
310 | - self['comments'] = 'A resposta não é numérica.' | |
312 | + self['comments'] = ('A resposta tem de ser numérica, ' | |
313 | + 'por exemplo `12.345`.') | |
311 | 314 | self['grade'] = 0.0 |
312 | 315 | else: |
313 | 316 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 |
... | ... | @@ -326,12 +329,12 @@ class QuestionTextArea(Question): |
326 | 329 | def __init__(self, q: QDict) -> None: |
327 | 330 | super().__init__(q) |
328 | 331 | |
329 | - self.set_defaults({ | |
332 | + self.set_defaults(QDict({ | |
330 | 333 | 'text': '', |
331 | 334 | 'timeout': 5, # seconds |
332 | 335 | 'correct': '', # trying to execute this will fail => grade 0.0 |
333 | 336 | 'args': [] |
334 | - }) | |
337 | + })) | |
335 | 338 | |
336 | 339 | self['correct'] = path.join(self['path'], self['correct']) |
337 | 340 | |
... | ... | @@ -396,11 +399,6 @@ class QuestionTextArea(Question): |
396 | 399 | |
397 | 400 | # =========================================================================== |
398 | 401 | class QuestionInformation(Question): |
399 | - '''An instance of QuestionInformation will always have the keys: | |
400 | - type (str) | |
401 | - text (str) | |
402 | - points (0.0) | |
403 | - ''' | |
404 | 402 | # ------------------------------------------------------------------------ |
405 | 403 | def __init__(self, q: QDict) -> None: |
406 | 404 | super().__init__(q) |
... | ... | @@ -412,3 +410,115 @@ class QuestionInformation(Question): |
412 | 410 | def correct(self) -> None: |
413 | 411 | super().correct() |
414 | 412 | self['grade'] = 1.0 # always "correct" but points should be zero! |
413 | + | |
414 | + | |
415 | +# =========================================================================== | |
416 | +# QFactory is a class that can generate question instances, e.g. by shuffling | |
417 | +# options, running a script to generate the question, etc. | |
418 | +# | |
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. | |
422 | +# | |
423 | +# Example: | |
424 | +# | |
425 | +# # generate a question instance from a dictionary | |
426 | +# qdict = { | |
427 | +# 'type': 'radio', | |
428 | +# 'text': 'Choose one', | |
429 | +# 'options': ['a', 'b'] | |
430 | +# } | |
431 | +# qfactory = QFactory(qdict) | |
432 | +# question = qfactory.generate() | |
433 | +# | |
434 | +# # answer one question and correct it | |
435 | +# question['answer'] = 42 # set answer | |
436 | +# question.correct() # correct answer | |
437 | +# grade = question['grade'] # get grade | |
438 | +# =========================================================================== | |
439 | +class QFactory(object): | |
440 | + # Depending on the type of question, a different question class will be | |
441 | + # instantiated. All these classes derive from the base class `Question`. | |
442 | + _types = { | |
443 | + 'radio': QuestionRadio, | |
444 | + 'checkbox': QuestionCheckbox, | |
445 | + 'text': QuestionText, | |
446 | + 'text-regex': QuestionTextRegex, | |
447 | + 'numeric-interval': QuestionNumericInterval, | |
448 | + 'textarea': QuestionTextArea, | |
449 | + # -- informative panels -- | |
450 | + 'information': QuestionInformation, | |
451 | + 'success': QuestionInformation, | |
452 | + 'warning': QuestionInformation, | |
453 | + 'alert': QuestionInformation, | |
454 | + } | |
455 | + | |
456 | + def __init__(self, qdict: QDict = QDict({})) -> None: | |
457 | + self.question = qdict | |
458 | + | |
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"]}"...') | |
496 | + # Shallow copy so that script generated questions will not replace | |
497 | + # the original generators | |
498 | + q = self.question.copy() | |
499 | + q['qid'] = str(uuid.uuid4()) # unique for each generated question | |
500 | + | |
501 | + # If question is of generator type, an external program will be run | |
502 | + # which will print a valid question in yaml format to stdout. This | |
503 | + # output is then yaml parsed into a dictionary `q`. | |
504 | + if q['type'] == 'generator': | |
505 | + logger.debug(f' \\_ Running "{q["script"]}".') | |
506 | + q.setdefault('args', []) | |
507 | + q.setdefault('stdin', '') # FIXME is it really necessary? | |
508 | + script = path.join(q['path'], q['script']) | |
509 | + out = await run_script_async(script=script, args=q['args'], | |
510 | + stdin=q['stdin']) | |
511 | + q.update(out) | |
512 | + | |
513 | + # Finally we create an instance of Question() | |
514 | + try: | |
515 | + qinstance = self._types[q['type']](QDict(q)) # of matching class | |
516 | + except QuestionException as e: | |
517 | + logger.error(e) | |
518 | + raise e | |
519 | + except KeyError: | |
520 | + logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') | |
521 | + raise | |
522 | + else: | |
523 | + logger.debug(f'[generate_async] Done instance of {q["ref"]}') | |
524 | + return qinstance | ... | ... |
perguntations/templates/question-textarea.html
perguntations/test.py
... | ... | @@ -5,10 +5,10 @@ import fnmatch |
5 | 5 | import random |
6 | 6 | from datetime import datetime |
7 | 7 | import logging |
8 | -import asyncio | |
9 | 8 | |
10 | 9 | # this project |
11 | -from perguntations.factory import QuestionFactory | |
10 | +from perguntations.questions import QFactory | |
11 | +from perguntations.tools import load_yaml | |
12 | 12 | |
13 | 13 | # Logger configuration |
14 | 14 | logger = logging.getLogger(__name__) |
... | ... | @@ -25,72 +25,101 @@ class TestFactoryException(Exception): |
25 | 25 | # instances of TestFactory(), one for each test. |
26 | 26 | # =========================================================================== |
27 | 27 | class TestFactory(dict): |
28 | - _defaults = { | |
29 | - 'title': '', | |
30 | - 'show_points': False, | |
31 | - 'scale_points': True, | |
32 | - 'scale_max': 20.0, | |
33 | - 'duration': 0, | |
34 | - # debug options: | |
35 | - 'debug': False, | |
36 | - 'show_ref': False | |
37 | - } | |
38 | - | |
39 | 28 | # ----------------------------------------------------------------------- |
40 | - # loads configuration from yaml file, then updates (overriding) | |
41 | - # some configurations using the conf argument. | |
42 | - # base questions are loaded from files into a pool. | |
29 | + # Loads configuration from yaml file, then overrides some configurations | |
30 | + # using the conf argument. | |
31 | + # Base questions are added to a pool of questions factories. | |
43 | 32 | # ----------------------------------------------------------------------- |
44 | 33 | def __init__(self, conf): |
45 | - super().__init__(conf) | |
46 | - | |
47 | - # --- defaults for optional keys | |
48 | - for k, v in self._defaults.items(): | |
49 | - self.setdefault(k, v) | |
50 | - | |
51 | - # set defaults and sanity checks | |
34 | + # --- set test configutation and defaults | |
35 | + super().__init__({ # defaults | |
36 | + 'title': '', | |
37 | + 'show_points': False, | |
38 | + 'scale_points': True, | |
39 | + 'scale_max': 20.0, | |
40 | + 'duration': 0, # infinite | |
41 | + 'debug': False, | |
42 | + 'show_ref': False | |
43 | + }) | |
44 | + self.update(conf) | |
45 | + | |
46 | + # --- perform sanity checks and normalize the test questions | |
52 | 47 | try: |
53 | 48 | self.sanity_checks() |
54 | 49 | except TestFactoryException: |
55 | 50 | logger.critical('Failed sanity checks in test factory.') |
56 | 51 | raise |
57 | 52 | |
58 | - if conf['review']: | |
59 | - logger.info('Review mode. No questions loaded.') | |
53 | + # --- for review, we are done. no factories needed | |
54 | + if self['review']: | |
55 | + logger.info('Review mode. No questions loaded. No factories.') | |
60 | 56 | return |
61 | 57 | |
62 | - # loads yaml files to question_factory | |
63 | - self.question_factory = QuestionFactory() | |
64 | - self.question_factory.load_files(files=self['files'], | |
65 | - questions_dir=self['questions_dir']) | |
66 | - | |
67 | - # check if all questions exist ('ref' keys are correct?) | |
68 | - logger.info('Checking questions for errors:') | |
69 | - nerrs = 0 | |
70 | - i = 0 | |
71 | - for q in self['questions']: | |
72 | - for r in q['ref']: | |
73 | - i += 1 | |
74 | - try: | |
75 | - self.question_factory.generate(r) | |
76 | - except Exception: | |
77 | - logger.error(f'Failed to generate "{r}".') | |
78 | - nerrs += 1 | |
79 | - else: | |
80 | - logger.info(f'{i:4}. "{r}" Ok.') | |
81 | - | |
82 | - if nerrs > 0: | |
83 | - logger.critical(f'Found {nerrs} errors generating questions.') | |
58 | + # --- find all questions in the test that need a factory | |
59 | + qrefs = [r for q in self['questions'] for r in q['ref']] | |
60 | + logger.debug(f'[TestFactory.__init__] test has {len(qrefs)} questions') | |
61 | + | |
62 | + # --- load and build question factories | |
63 | + self.question_factory = {} | |
64 | + | |
65 | + n, nerr = 0, 0 | |
66 | + for file in self["files"]: | |
67 | + fullpath = path.normpath(path.join(self["questions_dir"], file)) | |
68 | + (dirname, filename) = path.split(fullpath) | |
69 | + | |
70 | + questions = load_yaml(fullpath, default=[]) | |
71 | + | |
72 | + for i, q in enumerate(questions): | |
73 | + # make sure every question in the file is a dictionary | |
74 | + if not isinstance(q, dict): | |
75 | + logger.critical(f'Question {file}:{i} not a dictionary!') | |
76 | + raise TestFactoryException() | |
77 | + | |
78 | + # if ref is missing, then set to '/path/file.yaml:3' | |
79 | + q.setdefault('ref', f'{file}:{i}') | |
80 | + | |
81 | + # check for duplicate refs | |
82 | + if q['ref'] in self.question_factory: | |
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() | |
88 | + | |
89 | + # make factory only for the questions used in the test | |
90 | + if q['ref'] in qrefs: | |
91 | + q.update({ | |
92 | + 'filename': filename, | |
93 | + 'path': dirname, | |
94 | + 'index': i # position in the file, 0 based | |
95 | + }) | |
96 | + | |
97 | + q.setdefault('type', 'information') | |
98 | + | |
99 | + self.question_factory[q['ref']] = QFactory(q) | |
100 | + logger.debug(f'[TestFactory.__init__] QFactory: "{q["ref"]}".') | |
101 | + | |
102 | + # check if all the questions can be correctly generated | |
103 | + try: | |
104 | + self.question_factory[q['ref']].generate() | |
105 | + except Exception: | |
106 | + logger.error(f'Failed to generate "{q["ref"]}".') | |
107 | + nerr += 1 | |
108 | + else: | |
109 | + logger.info(f'{n:4}. "{q["ref"]}" Ok.') | |
110 | + n += 1 | |
111 | + | |
112 | + if nerr > 0: | |
113 | + logger.critical(f'Found {nerr} errors generating questions.') | |
84 | 114 | raise TestFactoryException() |
85 | 115 | else: |
86 | - logger.info(f'No errors found. Factory ready for "{self["ref"]}".') | |
116 | + logger.info(f'No errors found. Ready for "{self["ref"]}".') | |
87 | 117 | |
88 | - # ----------------------------------------------------------------------- | |
118 | + # ------------------------------------------------------------------------ | |
89 | 119 | # Checks for valid keys and sets default values. |
90 | 120 | # Also checks if some files and directories exist |
91 | - # ----------------------------------------------------------------------- | |
121 | + # ------------------------------------------------------------------------ | |
92 | 122 | def sanity_checks(self): |
93 | - | |
94 | 123 | # --- database |
95 | 124 | if 'database' not in self: |
96 | 125 | logger.critical('Missing "database" in configuration.') |
... | ... | @@ -147,7 +176,6 @@ class TestFactory(dict): |
147 | 176 | logger.critical(f'Missing "questions" in {self["filename"]}.') |
148 | 177 | raise TestFactoryException() |
149 | 178 | |
150 | - # normalize questions to a list of dictionaries | |
151 | 179 | for i, q in enumerate(self['questions']): |
152 | 180 | # normalize question to a dict and ref to a list of references |
153 | 181 | if isinstance(q, str): |
... | ... | @@ -157,50 +185,57 @@ class TestFactory(dict): |
157 | 185 | |
158 | 186 | self['questions'][i] = q |
159 | 187 | |
160 | - # ----------------------------------------------------------------------- | |
161 | - # Given a dictionary with a student id {'name':'john', 'number': 123} | |
188 | + # ------------------------------------------------------------------------ | |
189 | + # Given a dictionary with a student dict {'name':'john', 'number': 123} | |
162 | 190 | # returns instance of Test() for that particular student |
163 | - # ----------------------------------------------------------------------- | |
191 | + # ------------------------------------------------------------------------ | |
164 | 192 | async def generate(self, student): |
165 | 193 | test = [] |
166 | - total_points = 0.0 | |
194 | + # total_points = 0.0 | |
167 | 195 | |
168 | 196 | n = 1 |
169 | - loop = asyncio.get_running_loop() | |
170 | - qgenerator = self.question_factory.generate | |
197 | + nerr = 0 | |
171 | 198 | for qq in self['questions']: |
172 | - # generate Question() selected randomly from list of references | |
199 | + # choose one question variant | |
173 | 200 | qref = random.choice(qq['ref']) |
174 | 201 | |
202 | + # generate instance of question | |
175 | 203 | try: |
176 | - q = await loop.run_in_executor(None, qgenerator, qref) | |
204 | + q = await self.question_factory[qref].generate_async() | |
177 | 205 | except Exception: |
178 | 206 | logger.error(f'Can\'t generate question "{qref}". Skipping.') |
207 | + nerr += 1 | |
179 | 208 | continue |
180 | 209 | |
181 | 210 | # some defaults |
182 | 211 | if q['type'] in ('information', 'success', 'warning', 'alert'): |
183 | - q['points'] = qq.get('points', 0.0) | |
212 | + q.setdefault('points', 0.0) | |
184 | 213 | else: |
185 | - q['points'] = qq.get('points', 1.0) | |
186 | - q['number'] = n | |
214 | + q.setdefault('points', 1.0) | |
215 | + q['number'] = n # counter for non informative panels | |
187 | 216 | n += 1 |
188 | 217 | |
189 | - total_points += q['points'] | |
190 | 218 | test.append(q) |
191 | 219 | |
192 | 220 | # normalize question points to scale |
193 | 221 | if self['scale_points']: |
222 | + total_points = sum(q['points'] for q in test) | |
194 | 223 | for q in test: |
195 | - q['points'] *= self['scale_max'] / total_points | |
224 | + try: | |
225 | + q['points'] *= self['scale_max'] / total_points | |
226 | + except ZeroDivisionError: | |
227 | + logger.warning('Total points in the test is 0.0!!!') | |
228 | + q['points'] = 0.0 | |
229 | + | |
230 | + if nerr > 0: | |
231 | + logger.error(f'{nerr} errors found!') | |
196 | 232 | |
197 | 233 | return Test({ |
198 | 234 | 'ref': self['ref'], |
199 | 235 | 'title': self['title'], # title of the test |
200 | 236 | 'student': student, # student id |
201 | - 'questions': test, # list of questions | |
237 | + 'questions': test, # list of Question instances | |
202 | 238 | 'answers_dir': self['answers_dir'], |
203 | - | |
204 | 239 | 'duration': self['duration'], |
205 | 240 | 'show_points': self['show_points'], |
206 | 241 | 'show_ref': self['show_ref'], |
... | ... | @@ -217,11 +252,7 @@ class TestFactory(dict): |
217 | 252 | |
218 | 253 | |
219 | 254 | # =========================================================================== |
220 | -# Each instance of the Test() class is a concrete test to be answered by | |
221 | -# a single student. It must/will contain at least these keys: | |
222 | -# start_time, finish_time, questions, grade [0,20] | |
223 | -# Note: for the save_json() function other keys are required | |
224 | -# Note: grades are rounded to 1 decimal point: 0.0 - 20.0 | |
255 | +# Each instance Test() is a concrete test of a single student. | |
225 | 256 | # =========================================================================== |
226 | 257 | class Test(dict): |
227 | 258 | # ----------------------------------------------------------------------- |
... | ... | @@ -229,41 +260,34 @@ class Test(dict): |
229 | 260 | super().__init__(d) |
230 | 261 | self['start_time'] = datetime.now() |
231 | 262 | self['finish_time'] = None |
232 | - self['state'] = 'ONGOING' | |
263 | + self['state'] = 'ACTIVE' | |
233 | 264 | self['comment'] = '' |
234 | - logger.info(f'Student {self["student"]["number"]}: new test.') | |
235 | 265 | |
236 | 266 | # ----------------------------------------------------------------------- |
237 | 267 | # Removes all answers from the test (clean) |
238 | - # def reset_answers(self): | |
239 | - # for q in self['questions']: | |
240 | - # q['answer'] = None | |
241 | - # logger.info(f'Student {self["student"]["number"]}: answers cleared.') | |
268 | + def reset_answers(self): | |
269 | + for q in self['questions']: | |
270 | + q['answer'] = None | |
242 | 271 | |
243 | 272 | # ----------------------------------------------------------------------- |
244 | - # Given a dictionary ans={index: 'some answer'} updates the | |
273 | + # Given a dictionary ans={'ref': 'some answer'} updates the | |
245 | 274 | # answers of the test. Only affects questions referred. |
246 | 275 | def update_answers(self, ans): |
247 | 276 | for ref, answer in ans.items(): |
248 | 277 | self['questions'][ref]['answer'] = answer |
249 | - logger.info(f'Student {self["student"]["number"]}: ' | |
250 | - f'{len(ans)} answers updated.') | |
251 | 278 | |
252 | 279 | # ----------------------------------------------------------------------- |
253 | - # Corrects all the answers and computes the final grade | |
280 | + # Corrects all the answers of the test and computes the final grade | |
254 | 281 | async def correct(self): |
255 | 282 | self['finish_time'] = datetime.now() |
256 | 283 | self['state'] = 'FINISHED' |
257 | 284 | grade = 0.0 |
258 | 285 | for q in self['questions']: |
259 | 286 | await q.correct_async() |
260 | - | |
261 | 287 | grade += q['grade'] * q['points'] |
262 | - logger.debug(f'Correcting "{q["ref"]}": {q["grade"]*100.0} %') | |
288 | + logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%') | |
263 | 289 | |
264 | - self['grade'] = max(0, round(grade, 1)) # avoid negative grades | |
265 | - logger.info(f'Student {self["student"]["number"]}: ' | |
266 | - f'{self["grade"]} points.') | |
290 | + self['grade'] = max(0, round(grade, 1)) # truncate negative grades | |
267 | 291 | return self['grade'] |
268 | 292 | |
269 | 293 | # ----------------------------------------------------------------------- | ... | ... |