Commit 71a43de22472e04822444cd7be5eaf43f56bc17c

Authored by Miguel Barão
1 parent 8431c74d
Exists in master and in 1 other branch dev

- 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.
demo/demo.yaml 0 → 100644
@@ -0,0 +1,57 @@ @@ -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,7 +4,8 @@
4 ref: tut-test 4 ref: tut-test
5 title: Configuração do teste 5 title: Configuração do teste
6 text: | 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 A configuração contém a identificação do teste, base de dados dos alunos, 9 A configuração contém a identificação do teste, base de dados dos alunos,
9 ficheiros de perguntas a importar e uma selecção de perguntas e respectivas 10 ficheiros de perguntas a importar e uma selecção de perguntas e respectivas
10 cotações. 11 cotações.
@@ -13,44 +14,33 @@ @@ -13,44 +14,33 @@
13 14
14 ```yaml 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 files: 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 questions: 44 questions:
55 - ref: pergunta1 45 - ref: pergunta1
56 points: 3.5 46 points: 3.5
@@ -60,24 +50,23 @@ @@ -60,24 +50,23 @@
60 50
61 - ref: tabela-auxiliar 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 points: 0.5 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 - ref: pergunta4 58 - ref: pergunta4
69 59
70 - # ou ainda mais simples 60 + # se for uma string (não dict), é interpretada como referência
71 - pergunta5 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 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 vários turnos, não é
78 necessário alterar nada. 68 necessário alterar nada.
79 69
80 -  
81 # ---------------------------------------------------------------------------- 70 # ----------------------------------------------------------------------------
82 - type: information 71 - type: information
83 ref: tut-questions 72 ref: tut-questions
@@ -101,22 +90,22 @@ @@ -101,22 +90,22 @@
101 - 3 90 - 3
102 91
103 #----------------------------------------------------------------------------- 92 #-----------------------------------------------------------------------------
104 - - type: info 93 + - type: information
105 ref: chave-unica-2 94 ref: chave-unica-2
106 text: | 95 text: |
107 Quando o texto da pergunta tem várias linhas, dá jeito usar o símbolo 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 É o caso desta pergunta. 99 É o caso desta pergunta.
112 100
  101 + O texto das perguntas é escrito em `markdown`.
  102 +
113 #----------------------------------------------------------------------------- 103 #-----------------------------------------------------------------------------
114 ``` 104 ```
115 105
116 As chaves são usadas para construir o teste e não se podem repetir em 106 As chaves são usadas para construir o teste e não se podem repetir em
117 ficheiros diferentes. A seguir vamos ver exemplos de cada tipo de pergunta. 107 ficheiros diferentes. A seguir vamos ver exemplos de cada tipo de pergunta.
118 108
119 -  
120 # ---------------------------------------------------------------------------- 109 # ----------------------------------------------------------------------------
121 - type: radio 110 - type: radio
122 ref: tut-radio 111 ref: tut-radio
@@ -127,82 +116,91 @@ @@ -127,82 +116,91 @@
127 A utilização mais simples é a seguinte: 116 A utilização mais simples é a seguinte:
128 117
129 ```yaml 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 aleatória. 134 aleatória.
146 135
147 Para evitar que os alunos memorizem os textos das opções, podem definir-se 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 Por exemplo, se as 2 primeiras opções estiverem correctas e as restantes 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 ```yaml 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 options: 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 correct: [1, 1, 0, 0, 0] 174 correct: [1, 1, 0, 0, 0]
175 choose: 3 175 choose: 3
176 - shuffle: true  
177 solution: | 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 title: Escolha múltipla, várias opções correctas 182 title: Escolha múltipla, várias opções correctas
184 text: | 183 text: |
185 As perguntas de escolha múltipla permitem apresentar um conjunto de opções 184 As perguntas de escolha múltipla permitem apresentar um conjunto de opções
186 podendo ser seleccionadas várias em simultaneo. 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 ```yaml 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 Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada 206 Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada
@@ -211,35 +209,37 @@ @@ -211,35 +209,37 @@
211 Por exemplo se não seleccionar a opção 0, tem cotação -1, e não 209 Por exemplo se não seleccionar a opção 0, tem cotação -1, e não
212 seleccionando a opção 1 obtém-se +1. 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 Cada opção pode opcionalmente ser escrita como uma afirmação e o seu 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 ```yaml 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 simétrico. 235 simétrico.
233 -  
234 options: 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 correct: [1, -1, -1, 1] 241 correct: [1, -1, -1, 1]
240 - choose: 3  
241 - shuffle: true  
242 - 242 + shuffle: false
243 243
244 # ---------------------------------------------------------------------------- 244 # ----------------------------------------------------------------------------
245 - type: text 245 - type: text
@@ -247,41 +247,51 @@ @@ -247,41 +247,51 @@
247 title: Resposta de texto em linha 247 title: Resposta de texto em linha
248 text: | 248 text: |
249 Este tipo de perguntas permite uma resposta numa linha de texto. A resposta 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 ```yaml 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 correct: ['azul', 'Azul', 'AZUL'] 264 correct: ['azul', 'Azul', 'AZUL']
263 265
264 -  
265 # --------------------------------------------------------------------------- 266 # ---------------------------------------------------------------------------
266 - type: text-regex 267 - type: text-regex
267 ref: tut-text-regex 268 ref: tut-text-regex
268 - title: Resposta de texto em linha 269 + title: Resposta de texto em linha, expressão regular
269 text: | 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 ```yaml 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 Neste exemplo a expressão regular é `(VERDE|[Vv]erde)`. 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 - type: numeric-interval 297 - type: numeric-interval
@@ -289,23 +299,27 @@ @@ -289,23 +299,27 @@
289 title: Resposta numérica em linha de texto 299 title: Resposta numérica em linha de texto
290 text: | 300 text: |
291 Este tipo de perguntas esperam uma resposta numérica (vírgula flutuante). 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 indicado. 303 indicado.
294 304
295 ```yaml 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 Neste exemplo o intervalo de respostas correctas é [3.14, 3.15]. 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 correct: [3.14, 3.15] 319 correct: [3.14, 3.15]
306 solution: | 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 - type: textarea 325 - type: textarea
@@ -313,47 +327,61 @@ @@ -313,47 +327,61 @@
313 title: Resposta em múltiplas linhas de texto 327 title: Resposta em múltiplas linhas de texto
314 text: | 328 text: |
315 Este tipo de perguntas permitem respostas em múltiplas linhas de texto, que 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 ```yaml 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 Neste exemplo, o programa de avaliação é um script python que verifica se a 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 ```yaml 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 O comentário é mostrado na revisão de prova. 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 correct: correct/correct-question.py 382 correct: correct/correct-question.py
353 - lines: 3  
354 timeout: 5 383 timeout: 5
355 384
356 -  
357 # --------------------------------------------------------------------------- 385 # ---------------------------------------------------------------------------
358 - type: information 386 - type: information
359 ref: tut-information 387 ref: tut-information
@@ -361,37 +389,39 @@ @@ -361,37 +389,39 @@
361 text: | 389 text: |
362 As perguntas deste tipo não contam para avaliação. O objectivo é fornecer 390 As perguntas deste tipo não contam para avaliação. O objectivo é fornecer
363 instruções para os alunos, por exemplo tabelas para consulta, fórmulas, etc. 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 - type: success 420 - type: success
391 ref: tut-success 421 ref: tut-success
392 title: Texto informativo (sucesso) 422 title: Texto informativo (sucesso)
393 text: | 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 Além das fórmulas LaTeX, também se pode escrever troços de código: 426 Além das fórmulas LaTeX, também se pode escrever troços de código:
397 427
@@ -402,16 +432,16 @@ @@ -402,16 +432,16 @@
402 } 432 }
403 ``` 433 ```
404 434
405 - Faz-se assim: 435 + ---
406 436
407 - type: success 437 - type: success
408 ref: tut-success 438 ref: tut-success
409 title: Texto informativo (sucesso) 439 title: Texto informativo (sucesso)
410 text: | 440 text: |
411 Também não conta para avaliação. 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 ```C 446 ```C
417 int main() { 447 int main() {
@@ -420,7 +450,6 @@ @@ -420,7 +450,6 @@
420 } 450 }
421 ``` 451 ```
422 452
423 -  
424 # --------------------------------------------------------------------------- 453 # ---------------------------------------------------------------------------
425 - type: warning 454 - type: warning
426 ref: tut-warning 455 ref: tut-warning
@@ -430,13 +459,15 @@ @@ -430,13 +459,15 @@
430 459
431 Neste exemplo mostramos como se pode construir uma tabela como a seguinte: 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 ```yaml 472 ```yaml
442 - type: warning 473 - type: warning
@@ -445,23 +476,19 @@ @@ -445,23 +476,19 @@
445 text: | 476 text: |
446 Bla bla bla 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 - type: alert 487 - type: alert
461 ref: tut-alert 488 ref: tut-alert
462 title: Texto informativo (perigo) 489 title: Texto informativo (perigo)
463 text: | 490 text: |
464 - Texto importante. Não conta para avaliação. 491 + Não conta para avaliação. Texto importante.
465 492
466 ![planetas](planets.png "Planetas do Sistema Solar") 493 ![planetas](planets.png "Planetas do Sistema Solar")
467 494
@@ -473,5 +500,8 @@ @@ -473,5 +500,8 @@
473 - Imagens centradas com título: `![alt text](image.jpg "Título da imagem")`. 500 - Imagens centradas com título: `![alt text](image.jpg "Título da imagem")`.
474 O título aprece por baixo da imagem. O título pode ser uma string vazia. 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,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,6 +71,7 @@ class App(object):
71 testconf.update(conf) # configuration overrides from command line 71 testconf.update(conf) # configuration overrides from command line
72 72
73 # start test factory 73 # start test factory
  74 + logger.info(f'Creating test factory.')
74 try: 75 try:
75 self.testfactory = TestFactory(testconf) 76 self.testfactory = TestFactory(testconf)
76 except TestFactoryException: 77 except TestFactoryException:
@@ -147,7 +148,7 @@ class App(object): @@ -147,7 +148,7 @@ class App(object):
147 student_id = self.online[uid]['student'] # {number, name} 148 student_id = self.online[uid]['student'] # {number, name}
148 test = await self.testfactory.generate(student_id) 149 test = await self.testfactory.generate(student_id)
149 self.online[uid]['test'] = test 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 return self.online[uid]['test'] 152 return self.online[uid]['test']
152 else: 153 else:
153 # this implies an error in the code. should never be here! 154 # this implies an error in the code. should never be here!
@@ -158,19 +159,24 @@ class App(object): @@ -158,19 +159,24 @@ class App(object):
158 # for example: {0:'hello', 1:[1,2]} 159 # for example: {0:'hello', 1:[1,2]}
159 async def correct_test(self, uid, ans): 160 async def correct_test(self, uid, ans):
160 t = self.online[uid]['test'] 161 t = self.online[uid]['test']
  162 +
  163 + # --- submit answers and correct test
161 t.update_answers(ans) 164 t.update_answers(ans)
  165 + logger.info(f'Student {uid}: {len(ans)} answers submitted.')
  166 +
162 grade = await t.correct() 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 fname = ' -- '.join(fields) + '.json' 172 fname = ' -- '.join(fields) + '.json'
167 fpath = path.join(t['answers_dir'], fname) 173 fpath = path.join(t['answers_dir'], fname)
168 with open(path.expanduser(fpath), 'w') as f: 174 with open(path.expanduser(fpath), 'w') as f:
169 - # default=str required for datetime objects: 175 + # default=str required for datetime objects
170 json.dump(t, f, indent=2, default=str) 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 with self.db_session() as s: 180 with self.db_session() as s:
175 s.add(Test( 181 s.add(Test(
176 ref=t['ref'], 182 ref=t['ref'],
@@ -179,7 +185,7 @@ class App(object): @@ -179,7 +185,7 @@ class App(object):
179 starttime=str(t['start_time']), 185 starttime=str(t['start_time']),
180 finishtime=str(t['finish_time']), 186 finishtime=str(t['finish_time']),
181 filename=fpath, 187 filename=fpath,
182 - student_id=t['student']['number'], 188 + student_id=uid,
183 state=t['state'], 189 state=t['state'],
184 comment='')) 190 comment=''))
185 s.add_all([Question( 191 s.add_all([Question(
@@ -187,10 +193,10 @@ class App(object): @@ -187,10 +193,10 @@ class App(object):
187 grade=q['grade'], 193 grade=q['grade'],
188 starttime=str(t['start_time']), 194 starttime=str(t['start_time']),
189 finishtime=str(t['finish_time']), 195 finishtime=str(t['finish_time']),
190 - student_id=t['student']['number'], 196 + student_id=uid,
191 test_id=t['ref']) for q in t['questions'] if 'grade' in q]) 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 return grade 200 return grade
195 201
196 # ----------------------------------------------------------------------- 202 # -----------------------------------------------------------------------
perguntations/factory.py
@@ -1,173 +0,0 @@ @@ -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,7 +34,7 @@ class Test(Base):
34 ref = Column(String) 34 ref = Column(String)
35 title = Column(String) # FIXME depends on ref and should come from another table... 35 title = Column(String) # FIXME depends on ref and should come from another table...
36 grade = Column(Float) 36 grade = Column(Float)
37 - state = Column(String) # ONGOING, FINISHED, QUIT, NULL 37 + state = Column(String) # ACTIVE, FINISHED, QUIT, NULL
38 comment = Column(String) 38 comment = Column(String)
39 starttime = Column(String) 39 starttime = Column(String)
40 finishtime = Column(String) 40 finishtime = Column(String)
perguntations/questions.py
@@ -5,10 +5,10 @@ import re @@ -5,10 +5,10 @@ import re
5 from os import path 5 from os import path
6 import logging 6 import logging
7 from typing import Any, Dict, NewType 7 from typing import Any, Dict, NewType
8 -# import uuid 8 +import uuid
9 9
10 # this project 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 # setup logger for this module 13 # setup logger for this module
14 logger = logging.getLogger(__name__) 14 logger = logging.getLogger(__name__)
@@ -71,17 +71,18 @@ class QuestionRadio(Question): @@ -71,17 +71,18 @@ class QuestionRadio(Question):
71 ''' 71 '''
72 72
73 # ------------------------------------------------------------------------ 73 # ------------------------------------------------------------------------
  74 + # FIXME marking all options right breaks
74 def __init__(self, q: QDict) -> None: 75 def __init__(self, q: QDict) -> None:
75 super().__init__(q) 76 super().__init__(q)
76 77
77 n = len(self['options']) 78 n = len(self['options'])
78 79
79 - # set defaults if missing  
80 self.set_defaults(QDict({ 80 self.set_defaults(QDict({
81 'text': '', 81 'text': '',
82 'correct': 0, 82 'correct': 0,
83 'shuffle': True, 83 'shuffle': True,
84 'discount': True, 84 'discount': True,
  85 + 'max_tries': (n + 3) // 4 # 1 try for each 4 options
85 })) 86 }))
86 87
87 # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] 88 # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0]
@@ -97,11 +98,11 @@ class QuestionRadio(Question): @@ -97,11 +98,11 @@ class QuestionRadio(Question):
97 raise QuestionException(msg) 98 raise QuestionException(msg)
98 99
99 if self['shuffle']: 100 if self['shuffle']:
100 - # separate right from wrong options 101 + # lists with indices of right and wrong options
101 right = [i for i in range(n) if self['correct'][i] >= 1] 102 right = [i for i in range(n) if self['correct'][i] >= 1]
102 wrong = [i for i in range(n) if self['correct'][i] < 1] 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 # try to choose 1 correct option 107 # try to choose 1 correct option
107 if right: 108 if right:
@@ -124,7 +125,7 @@ class QuestionRadio(Question): @@ -124,7 +125,7 @@ class QuestionRadio(Question):
124 self['correct'] = [float(correct[i]) for i in perm] 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 def correct(self) -> None: 129 def correct(self) -> None:
129 super().correct() 130 super().correct()
130 131
@@ -157,13 +158,14 @@ class QuestionCheckbox(Question): @@ -157,13 +158,14 @@ class QuestionCheckbox(Question):
157 n = len(self['options']) 158 n = len(self['options'])
158 159
159 # set defaults if missing 160 # set defaults if missing
160 - self.set_defaults({ 161 + self.set_defaults(QDict({
161 'text': '', 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 'shuffle': True, 164 'shuffle': True,
164 'discount': True, 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 if len(self['correct']) != n: 170 if len(self['correct']) != n:
169 msg = (f'Options and correct size mismatch in ' 171 msg = (f'Options and correct size mismatch in '
@@ -172,23 +174,26 @@ class QuestionCheckbox(Question): @@ -172,23 +174,26 @@ class QuestionCheckbox(Question):
172 raise QuestionException(msg) 174 raise QuestionException(msg)
173 175
174 # if an option is a list of (right, wrong), pick one 176 # if an option is a list of (right, wrong), pick one
175 - # FIXME it's possible that all options are chosen wrong  
176 options = [] 177 options = []
177 correct = [] 178 correct = []
178 for o, c in zip(self['options'], self['correct']): 179 for o, c in zip(self['options'], self['correct']):
179 if isinstance(o, list): 180 if isinstance(o, list):
180 r = random.randint(0, 1) 181 r = random.randint(0, 1)
181 o = o[r] 182 o = o[r]
182 - c = c if r == 0 else -c 183 + if r == 1:
  184 + c = -c
183 options.append(str(o)) 185 options.append(str(o))
184 correct.append(float(c)) 186 correct.append(float(c))
185 187
186 # generate random permutation, e.g. [2,1,4,0,3] 188 # generate random permutation, e.g. [2,1,4,0,3]
187 # and apply to `options` and `correct` 189 # and apply to `options` and `correct`
188 if self['shuffle']: 190 if self['shuffle']:
189 - perm = random.sample(range(n), self['choose']) 191 + perm = random.sample(range(n), k=self['choose'])
190 self['options'] = [options[i] for i in perm] 192 self['options'] = [options[i] for i in perm]
191 self['correct'] = [correct[i] for i in perm] 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 # can return negative values for wrong answers 199 # can return negative values for wrong answers
@@ -213,7 +218,7 @@ class QuestionCheckbox(Question): @@ -213,7 +218,7 @@ class QuestionCheckbox(Question):
213 self['grade'] = x / sum_abs 218 self['grade'] = x / sum_abs
214 219
215 220
216 -# ============================================================================ 221 +# ===========================================================================
217 class QuestionText(Question): 222 class QuestionText(Question):
218 '''An instance of QuestionText will always have the keys: 223 '''An instance of QuestionText will always have the keys:
219 type (str) 224 type (str)
@@ -226,10 +231,10 @@ class QuestionText(Question): @@ -226,10 +231,10 @@ class QuestionText(Question):
226 def __init__(self, q: QDict) -> None: 231 def __init__(self, q: QDict) -> None:
227 super().__init__(q) 232 super().__init__(q)
228 233
229 - self.set_defaults({ 234 + self.set_defaults(QDict({
230 'text': '', 235 'text': '',
231 'correct': [], 236 'correct': [],
232 - }) 237 + }))
233 238
234 # make sure its always a list of possible correct answers 239 # make sure its always a list of possible correct answers
235 if not isinstance(self['correct'], list): 240 if not isinstance(self['correct'], list):
@@ -239,7 +244,6 @@ class QuestionText(Question): @@ -239,7 +244,6 @@ class QuestionText(Question):
239 self['correct'] = [str(a) for a in self['correct']] 244 self['correct'] = [str(a) for a in self['correct']]
240 245
241 # ------------------------------------------------------------------------ 246 # ------------------------------------------------------------------------
242 - # can return negative values for wrong answers  
243 def correct(self) -> None: 247 def correct(self) -> None:
244 super().correct() 248 super().correct()
245 249
@@ -260,13 +264,12 @@ class QuestionTextRegex(Question): @@ -260,13 +264,12 @@ class QuestionTextRegex(Question):
260 def __init__(self, q: QDict) -> None: 264 def __init__(self, q: QDict) -> None:
261 super().__init__(q) 265 super().__init__(q)
262 266
263 - self.set_defaults({ 267 + self.set_defaults(QDict({
264 'text': '', 268 'text': '',
265 'correct': '$.^', # will always return false 269 'correct': '$.^', # will always return false
266 - }) 270 + }))
267 271
268 # ------------------------------------------------------------------------ 272 # ------------------------------------------------------------------------
269 - # can return negative values for wrong answers  
270 def correct(self) -> None: 273 def correct(self) -> None:
271 super().correct() 274 super().correct()
272 if self['answer'] is not None: 275 if self['answer'] is not None:
@@ -298,7 +301,6 @@ class QuestionNumericInterval(Question): @@ -298,7 +301,6 @@ class QuestionNumericInterval(Question):
298 })) 301 }))
299 302
300 # ------------------------------------------------------------------------ 303 # ------------------------------------------------------------------------
301 - # can return negative values for wrong answers  
302 def correct(self) -> None: 304 def correct(self) -> None:
303 super().correct() 305 super().correct()
304 if self['answer'] is not None: 306 if self['answer'] is not None:
@@ -307,7 +309,8 @@ class QuestionNumericInterval(Question): @@ -307,7 +309,8 @@ class QuestionNumericInterval(Question):
307 try: # replace , by . and convert to float 309 try: # replace , by . and convert to float
308 answer = float(self['answer'].replace(',', '.', 1)) 310 answer = float(self['answer'].replace(',', '.', 1))
309 except ValueError: 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 self['grade'] = 0.0 314 self['grade'] = 0.0
312 else: 315 else:
313 self['grade'] = 1.0 if lower <= answer <= upper else 0.0 316 self['grade'] = 1.0 if lower <= answer <= upper else 0.0
@@ -326,12 +329,12 @@ class QuestionTextArea(Question): @@ -326,12 +329,12 @@ class QuestionTextArea(Question):
326 def __init__(self, q: QDict) -> None: 329 def __init__(self, q: QDict) -> None:
327 super().__init__(q) 330 super().__init__(q)
328 331
329 - self.set_defaults({ 332 + self.set_defaults(QDict({
330 'text': '', 333 'text': '',
331 'timeout': 5, # seconds 334 'timeout': 5, # seconds
332 'correct': '', # trying to execute this will fail => grade 0.0 335 'correct': '', # trying to execute this will fail => grade 0.0
333 'args': [] 336 'args': []
334 - }) 337 + }))
335 338
336 self['correct'] = path.join(self['path'], self['correct']) 339 self['correct'] = path.join(self['path'], self['correct'])
337 340
@@ -396,11 +399,6 @@ class QuestionTextArea(Question): @@ -396,11 +399,6 @@ class QuestionTextArea(Question):
396 399
397 # =========================================================================== 400 # ===========================================================================
398 class QuestionInformation(Question): 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 def __init__(self, q: QDict) -> None: 403 def __init__(self, q: QDict) -> None:
406 super().__init__(q) 404 super().__init__(q)
@@ -412,3 +410,115 @@ class QuestionInformation(Question): @@ -412,3 +410,115 @@ class QuestionInformation(Question):
412 def correct(self) -> None: 410 def correct(self) -> None:
413 super().correct() 411 super().correct()
414 self['grade'] = 1.0 # always "correct" but points should be zero! 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
@@ -2,6 +2,6 @@ @@ -2,6 +2,6 @@
2 2
3 {% block answer %} 3 {% block answer %}
4 4
5 -<textarea class="form-control" rows="{{q['lines']}}" name="{{i}}">{{q['answer'] or ''}}</textarea><br /> 5 +<textarea class="form-control" name="{{i}}">{{q['answer'] or ''}}</textarea><br />
6 6
7 {% end %} 7 {% end %}
perguntations/test.py
@@ -5,10 +5,10 @@ import fnmatch @@ -5,10 +5,10 @@ import fnmatch
5 import random 5 import random
6 from datetime import datetime 6 from datetime import datetime
7 import logging 7 import logging
8 -import asyncio  
9 8
10 # this project 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 # Logger configuration 13 # Logger configuration
14 logger = logging.getLogger(__name__) 14 logger = logging.getLogger(__name__)
@@ -25,72 +25,101 @@ class TestFactoryException(Exception): @@ -25,72 +25,101 @@ class TestFactoryException(Exception):
25 # instances of TestFactory(), one for each test. 25 # instances of TestFactory(), one for each test.
26 # =========================================================================== 26 # ===========================================================================
27 class TestFactory(dict): 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 def __init__(self, conf): 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 try: 47 try:
53 self.sanity_checks() 48 self.sanity_checks()
54 except TestFactoryException: 49 except TestFactoryException:
55 logger.critical('Failed sanity checks in test factory.') 50 logger.critical('Failed sanity checks in test factory.')
56 raise 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 return 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 raise TestFactoryException() 114 raise TestFactoryException()
85 else: 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 # Checks for valid keys and sets default values. 119 # Checks for valid keys and sets default values.
90 # Also checks if some files and directories exist 120 # Also checks if some files and directories exist
91 - # ----------------------------------------------------------------------- 121 + # ------------------------------------------------------------------------
92 def sanity_checks(self): 122 def sanity_checks(self):
93 -  
94 # --- database 123 # --- database
95 if 'database' not in self: 124 if 'database' not in self:
96 logger.critical('Missing "database" in configuration.') 125 logger.critical('Missing "database" in configuration.')
@@ -147,7 +176,6 @@ class TestFactory(dict): @@ -147,7 +176,6 @@ class TestFactory(dict):
147 logger.critical(f'Missing "questions" in {self["filename"]}.') 176 logger.critical(f'Missing "questions" in {self["filename"]}.')
148 raise TestFactoryException() 177 raise TestFactoryException()
149 178
150 - # normalize questions to a list of dictionaries  
151 for i, q in enumerate(self['questions']): 179 for i, q in enumerate(self['questions']):
152 # normalize question to a dict and ref to a list of references 180 # normalize question to a dict and ref to a list of references
153 if isinstance(q, str): 181 if isinstance(q, str):
@@ -157,50 +185,57 @@ class TestFactory(dict): @@ -157,50 +185,57 @@ class TestFactory(dict):
157 185
158 self['questions'][i] = q 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 # returns instance of Test() for that particular student 190 # returns instance of Test() for that particular student
163 - # ----------------------------------------------------------------------- 191 + # ------------------------------------------------------------------------
164 async def generate(self, student): 192 async def generate(self, student):
165 test = [] 193 test = []
166 - total_points = 0.0 194 + # total_points = 0.0
167 195
168 n = 1 196 n = 1
169 - loop = asyncio.get_running_loop()  
170 - qgenerator = self.question_factory.generate 197 + nerr = 0
171 for qq in self['questions']: 198 for qq in self['questions']:
172 - # generate Question() selected randomly from list of references 199 + # choose one question variant
173 qref = random.choice(qq['ref']) 200 qref = random.choice(qq['ref'])
174 201
  202 + # generate instance of question
175 try: 203 try:
176 - q = await loop.run_in_executor(None, qgenerator, qref) 204 + q = await self.question_factory[qref].generate_async()
177 except Exception: 205 except Exception:
178 logger.error(f'Can\'t generate question "{qref}". Skipping.') 206 logger.error(f'Can\'t generate question "{qref}". Skipping.')
  207 + nerr += 1
179 continue 208 continue
180 209
181 # some defaults 210 # some defaults
182 if q['type'] in ('information', 'success', 'warning', 'alert'): 211 if q['type'] in ('information', 'success', 'warning', 'alert'):
183 - q['points'] = qq.get('points', 0.0) 212 + q.setdefault('points', 0.0)
184 else: 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 n += 1 216 n += 1
188 217
189 - total_points += q['points']  
190 test.append(q) 218 test.append(q)
191 219
192 # normalize question points to scale 220 # normalize question points to scale
193 if self['scale_points']: 221 if self['scale_points']:
  222 + total_points = sum(q['points'] for q in test)
194 for q in test: 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 return Test({ 233 return Test({
198 'ref': self['ref'], 234 'ref': self['ref'],
199 'title': self['title'], # title of the test 235 'title': self['title'], # title of the test
200 'student': student, # student id 236 'student': student, # student id
201 - 'questions': test, # list of questions 237 + 'questions': test, # list of Question instances
202 'answers_dir': self['answers_dir'], 238 'answers_dir': self['answers_dir'],
203 -  
204 'duration': self['duration'], 239 'duration': self['duration'],
205 'show_points': self['show_points'], 240 'show_points': self['show_points'],
206 'show_ref': self['show_ref'], 241 'show_ref': self['show_ref'],
@@ -217,11 +252,7 @@ class TestFactory(dict): @@ -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 class Test(dict): 257 class Test(dict):
227 # ----------------------------------------------------------------------- 258 # -----------------------------------------------------------------------
@@ -229,41 +260,34 @@ class Test(dict): @@ -229,41 +260,34 @@ class Test(dict):
229 super().__init__(d) 260 super().__init__(d)
230 self['start_time'] = datetime.now() 261 self['start_time'] = datetime.now()
231 self['finish_time'] = None 262 self['finish_time'] = None
232 - self['state'] = 'ONGOING' 263 + self['state'] = 'ACTIVE'
233 self['comment'] = '' 264 self['comment'] = ''
234 - logger.info(f'Student {self["student"]["number"]}: new test.')  
235 265
236 # ----------------------------------------------------------------------- 266 # -----------------------------------------------------------------------
237 # Removes all answers from the test (clean) 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 # answers of the test. Only affects questions referred. 274 # answers of the test. Only affects questions referred.
246 def update_answers(self, ans): 275 def update_answers(self, ans):
247 for ref, answer in ans.items(): 276 for ref, answer in ans.items():
248 self['questions'][ref]['answer'] = answer 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 async def correct(self): 281 async def correct(self):
255 self['finish_time'] = datetime.now() 282 self['finish_time'] = datetime.now()
256 self['state'] = 'FINISHED' 283 self['state'] = 'FINISHED'
257 grade = 0.0 284 grade = 0.0
258 for q in self['questions']: 285 for q in self['questions']:
259 await q.correct_async() 286 await q.correct_async()
260 -  
261 grade += q['grade'] * q['points'] 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 return self['grade'] 291 return self['grade']
268 292
269 # ----------------------------------------------------------------------- 293 # -----------------------------------------------------------------------