Commit 71a43de22472e04822444cd7be5eaf43f56bc17c
1 parent
8431c74d
Exists in
master
and in
1 other branch
- updates to demo.
- shares same questions.py as aprendizations. - rewrite of TestFactory to use the same QFactory as aprendizations. removes factory.py. - fix async functions to always use the same thread (no executor), also fixes sqlalchemy threading errors. - fix textarea lines. - fix checkbox with shuffle set to false. - fix error in scale_points when total points is zero.
Showing
9 changed files
with
571 additions
and
574 deletions
Show diff stats
@@ -0,0 +1,57 @@ | @@ -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 |  | 493 |  |
467 | 494 | ||
@@ -473,5 +500,8 @@ | @@ -473,5 +500,8 @@ | ||
473 | - Imagens centradas com título: ``. | 500 | - Imagens centradas com título: ``. |
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 | # ----------------------------------------------------------------------- |