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