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 | # ----------------------------------------------------------------------- |