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 @@
  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 ![planetas](planets.png "Planetas do Sistema Solar")
467 494  
... ... @@ -473,5 +500,8 @@
473 500 - Imagens centradas com título: `![alt text](image.jpg "Título da imagem")`.
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
... ... @@ -2,6 +2,6 @@
2 2  
3 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 7 {% end %}
... ...
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 # -----------------------------------------------------------------------
... ...