diff --git a/demo/demo.yaml b/demo/demo.yaml
new file mode 100644
index 0000000..ed64539
--- /dev/null
+++ b/demo/demo.yaml
@@ -0,0 +1,57 @@
+---
+# ============================================================================
+# The test reference should be a unique identifier. It is saved in the database
+# so that queries for the results can be done in the terminal with
+# $ sqlite3 students.db "select * from tests where ref='demo'"
+ref: tutorial
+
+# (optional, default: '') You may wish to refer the course, year or kind of test
+title: Teste de demonstração (tutorial)
+
+# (optional) duration in minutes, 0 or undefined is infinite
+duration: 60
+
+# Database with student credentials and grades of all questions and tests done
+# The database is an sqlite3 file generate with the script initdb.py
+database: students.db
+
+# Generate a file for each test done by a student.
+# It includes the questions, answers and grades.
+answers_dir: ans
+
+# (optional, default: False) Show points for each question, scale 0-20.
+show_points: true
+# scale_points: true
+# scale_max: 20
+
+# ----------------------------------------------------------------------------
+# Base path applied to the questions files and all the scripts
+# including question generators and correctors.
+# Either absolute path or relative to current directory can be used.
+questions_dir: .
+
+# (optional) List of files containing questions in yaml format.
+# Selected questions will be obtained from these files.
+# If undefined, all yaml files in questions_dir are loaded (not recommended).
+files:
+ - questions/questions-tutorial.yaml
+
+# This is the list of questions that will make up the test.
+# The order is preserved.
+# There are several ways to define each question (explained below).
+questions:
+ - tut-test
+ - tut-questions
+
+ - tut-radio
+ - tut-checkbox
+ - tut-text
+ - tut-text-regex
+ - tut-numeric-interval
+ - ref: tut-textarea
+ points: 2.0
+
+ - tut-information
+ - tut-success
+ - tut-warning
+ - tut-alert
diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml
index 5299700..bbe1fc1 100644
--- a/demo/questions/questions-tutorial.yaml
+++ b/demo/questions/questions-tutorial.yaml
@@ -4,7 +4,8 @@
ref: tut-test
title: Configuração do teste
text: |
- O teste é configurado num ficheiro `yaml` (ver especificação [aqui](https://yaml.org)).
+ O teste é configurado num ficheiro `yaml`
+ (ver especificação [aqui](https://yaml.org)).
A configuração contém a identificação do teste, base de dados dos alunos,
ficheiros de perguntas a importar e uma selecção de perguntas e respectivas
cotações.
@@ -13,44 +14,33 @@
```yaml
---
- # ----------------------------------------------------------------------------
- # ref: Referência do teste. Pode ser reusada em vários turnos
- # title: Título do teste
- # database: Base de dados previamente inicializada com os alunos (usando initdb.py)
- # answers_dir: Directório onde vão ficar guardados os testes dos alunos
- ref: tutorial
- title: Teste de Avaliação
- database: demo/students.db
- answers_dir: demo/ans
-
- # Duração do teste em minutos, apenas informativo. (default: infinito)
- duration: 60
-
- # Mostrar cotação das perguntas. (default: false)
- show_points: true
-
- # Pontos das perguntas são automaticamente convertidos para a escala dada
- # (defaults: true, 20)
- scale_points: true
- scale_max: 20
-
- # (opcional, default: false)
- debug: false
-
- # ----------------------------------------------------------------------------
- # Directório base onde estão as perguntas
- questions_dir: ~/topics/P1
-
- # Ficheiros de perguntas a importar (relativamente a ~/topics/P1)
+ # --------------------------------------------------------------------------
+ ref: tutorial # referência, pode ser reusada em vários turnos
+ title: Demonstração # título da prova
+ database: students.db # base de dados já inicializada com initdb
+ answers_dir: ans # directório onde ficam os testes entregues
+
+ # opcional
+ duration: 60 # duração da prova em minutos (default=inf)
+ show_points: true # mostra cotação das perguntas
+ scale_points: true # recalcula cotações para a escala [0, scale_max]
+ scale_max: 20 # limite superior da escala
+ debug: false # mostra informação de debug na prova (browser)
+
+ # --------------------------------------------------------------------------
+ questions_dir: ~/topics # raíz da árvore de directórios das perguntas
+
+ # Ficheiros de perguntas a importar (relativamente a `questions_dir`)
files:
- - topico_A/parte_1/questions.yaml
- - topico_A/parte_2/questions.yaml
- - topico_B/questions.yaml
-
- # ----------------------------------------------------------------------------
- # Especificação das perguntas do teste e cotações.
- # O teste é uma lista de perguntas
- # Cada pergunta é um dicionário com ref da pergunta e cotação.
+ - tabelas.yaml
+ - topic_A/questions.yaml
+ - topic_B/part_1/questions.yaml
+ - topic_B/part_2/questions.yaml
+
+ # --------------------------------------------------------------------------
+ # Especificação das perguntas do teste e respectivas cotações.
+ # O teste é uma lista de perguntas, onde cada pergunta é especificada num
+ # dicionário com a referência da pergunta e a respectiva cotação.
questions:
- ref: pergunta1
points: 3.5
@@ -60,24 +50,23 @@
- ref: tabela-auxiliar
- # escolhe uma das seguintes aleatoriamente
- - ref: [ pergunta3a, pergunta3b ]
+ # escolhe aleatoriamente uma das variantes
+ - ref: [pergunta3a, pergunta3b]
points: 0.5
- # a cotação é 1.0 por defeito, caso não esteja definida
+ # a cotação é 1.0 por defeito (se omitida)
- ref: pergunta4
- # ou ainda mais simples
+ # se for uma string (não dict), é interpretada como referência
- pergunta5
- # ----------------------------------------------------------------------------
+ # --------------------------------------------------------------------------
```
- A ordem das perguntas é mantida.
+ A ordem das perguntas é mantida quando apresentada para o aluno.
O mesmo teste pode ser realizado várias vezes em vários turnos, não é
necessário alterar nada.
-
# ----------------------------------------------------------------------------
- type: information
ref: tut-questions
@@ -101,22 +90,22 @@
- 3
#-----------------------------------------------------------------------------
- - type: info
+ - type: information
ref: chave-unica-2
text: |
Quando o texto da pergunta tem várias linhas, dá jeito usar o símbolo
- pipe, para indicar que tudo o que estiver indentado relativamente à
- linha `text: |` faz parte do corpo do texto.
-
+ `|` de pipe, para indicar que tudo o que estiver indentado faz parte do
+ texto.
É o caso desta pergunta.
+ O texto das perguntas é escrito em `markdown`.
+
#-----------------------------------------------------------------------------
```
As chaves são usadas para construir o teste e não se podem repetir em
ficheiros diferentes. A seguir vamos ver exemplos de cada tipo de pergunta.
-
# ----------------------------------------------------------------------------
- type: radio
ref: tut-radio
@@ -127,82 +116,91 @@
A utilização mais simples é a seguinte:
```yaml
- - type: radio
- ref: pergunta-1
- title: Escolha simples, uma opção correcta.
- text: |
- Bla bla bla.
- options:
- - Opção 0
- - Opção 1
- - Opção 2
- - Opção 3
- - Opção 4
+ - type: radio
+ ref: pergunta-1
+ title: Escolha simples, uma opção correcta.
+ text: |
+ Bla bla bla.
+ options:
+ - Opção 0
+ - Opção 1
+ - Opção 2
+ - Opção 3
+ - Opção 4
```
- Sem outras configurações, assume-se que a primeira opção ("Opção 0" neste
- caso) é a resposta correcta, e todas as 5 opções são apresentadas por ordem
+ Sem outras configurações, assume-se que a primeira opção é a resposta
+ correcta ("Opção 0" neste caso) e as 5 opções são apresentadas por ordem
aleatória.
Para evitar que os alunos memorizem os textos das opções, podem definir-se
- várias opções correctas com escrita ligeiramente diferente, sendo
- apresentada apenas uma delas.
+ várias opções correctas com escrita ligeiramente diferente, sendo escolhida
+ apenas uma delas para apresentação.
Por exemplo, se as 2 primeiras opções estiverem correctas e as restantes
- erradas, e quisermos apresentar 3 opções no total com uma delas correcta
- adiciona-se:
+ erradas, e quisermos apresentar ao aluno 3 opções no total, acrescenta-se:
```yaml
- correct: [1, 1, 0, 0, 0]
- choose: 3
+ correct: [1, 1, 0, 0, 0]
+ choose: 3
```
- Assim será escolhida uma opção certa e mais 2 opções erradas.
+ Neste caso, será escolhida uma opção certa de entre o conjunto das certas
+ e duas erradas de entre o conjunto das erradas.
+ Os valores em `correct` representam o grau de correcção no intervalo [0, 1]
+ onde 1 representa 100% certo e 0 representa 0%.
+
+ Por defeito, as opções são apresentadas por ordem aleatória.
+ Para manter a ordem acrescenta-se:
- Por defeito, as opções são sempre baralhadas. Adicionando `shuffle: False`
- evita que o sejam.
+ ```yaml
+ shuffle: false
+ ```
- Por defeito, as respostas erradas descontam 1/(n-1) do valor da pergunta,
- onde n é o número de opções apresentadas. Para não descontar usa-se
- `discount: False`.
+ Por defeito, as respostas erradas descontam, tendo uma cotação de -1/(n-1)
+ do valor da pergunta, onde n é o número de opções apresentadas ao aluno
+ (a ideia é o valor esperado ser zero quando as respostas são aleatórias e
+ uniformemente distribuídas).
+ Para não descontar acrescenta-se:
+ ```yaml
+ discount: false
+ ```
options:
- - Opção 0
- - Opção 1
- - Opção 2
- - Opção 3
- - Opção 4
+ - Opção 0 (certa)
+ - Opção 1 (certa)
+ - Opção 2
+ - Opção 3
+ - Opção 4
correct: [1, 1, 0, 0, 0]
choose: 3
- shuffle: true
solution: |
- A solução correcta é a **opção 0**.
+ A solução correcta é a **Opção 0** ou a **Opção 1**.
# ----------------------------------------------------------------------------
-- type: checkbox
- ref: tut-checkbox
+- ref: tut-checkbox
+ type: checkbox
title: Escolha múltipla, várias opções correctas
text: |
As perguntas de escolha múltipla permitem apresentar um conjunto de opções
podendo ser seleccionadas várias em simultaneo.
- Funcionam como múltiplas perguntas independentes com a cotação indicada em
- `correct`. As opções não seleccionadas têm a cotação simétrica à indicada.
- Deste modo, um aluno só deve responder se tiver confiança em pelo menos
- metade das respostas, caso contrário arrisca-se a ter cotação negativa na
- pergunta.
+ Funcionam como múltiplas perguntas independentes de resposta sim/não.
+
+ Cada opção seleccionada (`sim`) recebe a cotação indicada em `correct`.
+ Cada opção não seleccionadas (`não`) tem a cotação simétrica.
```yaml
- - type: checkbox
- ref: tut-checkbox
- title: Escolha múltipla, várias opções correctas
- text: |
- Bla bla bla.
- options:
- - Opção 0
- - Opção 1
- - Opção 2
- - Opção 3
- - Opção 4
- correct: [1, -1, -1, 1, -1]
+ - type: checkbox
+ ref: tut-checkbox
+ title: Escolha múltipla, várias opções correctas
+ text: |
+ Bla bla bla.
+ options:
+ - Opção 0 (certa)
+ - Opção 1
+ - Opção 2
+ - Opção 3 (certa)
+ - Opção 4
+ correct: [1, -1, -1, 1, -1]
```
Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada
@@ -211,35 +209,37 @@
Por exemplo se não seleccionar a opção 0, tem cotação -1, e não
seleccionando a opção 1 obtém-se +1.
+ *(Note que o `correct` não funciona do mesmo modo que nas perguntas do
+ tipo `radio`. Em geral, um aluno só deve responder se souber mais de metade
+ das respostas, caso contrário arrisca-se a ter cotação negativa na
+ pergunta. Não há forma de não responder a apenas algumas delas.)*
+
Cada opção pode opcionalmente ser escrita como uma afirmação e o seu
- contrário, de maneira a dar mais aleatoriedade à apresentação deste tipo de
- perguntas. Por exemplo:
+ contrário, de maneira a aumentar a variabilidade dos textos.
+ Por exemplo:
```yaml
- options:
- - ["O céu é azul", "O céu não é azul"]
- - ["Um triangulo tem 3 lados", "Um triangulo tem 2 lados"]
- - O nosso planeta tem um satélite natural
- correct: [1, 1, 1]
+ options:
+ - ["O céu é azul", "O céu não é azul"]
+ - ["Um triangulo tem 3 lados", "Um triangulo tem 2 lados"]
+ - O nosso planeta tem um satélite natural
+ correct: [1, 1, 1]
```
- Assume-se que a primeira alternativa de cada opção tem a cotação +1,
- enquanto a segunda alternativa tem a cotação simétrica -1 (desconta se for
- seleccionada).
+ Assume-se que a primeira alternativa de cada opção tem a cotação indicada
+ em `correct`, enquanto a segunda alternativa tem a cotação simétrica.
- Estão disponíveis as configurações `shuffle` e `discount`.
- Se `discount: False` então as respostas erradas têm cotação 0 em vez do
+ Tal como nas perguntas do tipo `radio`, podem ser usadas as configurações
+ `shuffle` e `discount` com valor `false` para as desactivar.
+ Se `discount` é `false` então as respostas erradas têm cotação 0 em vez do
simétrico.
-
options:
- - Opção 0 (sim)
- - Opção 1 (não)
- - Opção 2 (não)
- - Opção 3 (sim)
+ - ['Opção 0 (sim)', 'Opção 0 (não)']
+ - ['Opção 1 (não)', 'Opção 1 (sim)']
+ - Opção 2 (não)
+ - Opção 3 (sim)
correct: [1, -1, -1, 1]
- choose: 3
- shuffle: true
-
+ shuffle: false
# ----------------------------------------------------------------------------
- type: text
@@ -247,41 +247,51 @@
title: Resposta de texto em linha
text: |
Este tipo de perguntas permite uma resposta numa linha de texto. A resposta
- está correcta se coincidir com alguma das respostas admissíveis.
+ está correcta se coincidir exactamente com alguma das respostas admissíveis.
```yaml
- - type: text
- ref: tut-text
- title: Resposta de texto em linha
- text: |
- Bla bla bla
- correct: ['azul', 'Azul', 'AZUL']
+ - type: text
+ ref: tut-text
+ title: Resposta de texto em linha
+ text: |
+ De que cor é o céu?
+
+ Escreva a resposta em português.
+ correct: ['azul', 'Azul', 'AZUL']
```
- Neste exemplo a resposta correcta é `azul`, `Azul` ou `AZUL`.
+ Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`.
correct: ['azul', 'Azul', 'AZUL']
-
# ---------------------------------------------------------------------------
- type: text-regex
ref: tut-text-regex
- title: Resposta de texto em linha
+ title: Resposta de texto em linha, expressão regular
text: |
- Este tipo de pergunta é semelhante à linha de texto da pergunta anterior. A
- única diferença é que esta é validada por uma expressão regular.
+ Este tipo de pergunta é semelhante à linha de texto da pergunta anterior.
+ A única diferença é que esta é validada por uma expressão regular.
```yaml
- - type: text-regex
- ref: tut-text-regex
- title: Resposta de texto em linha
- text: |
- Bla bla bla
- correct: !regex '(VERDE|[Vv]erde)'
+ - type: text-regex
+ ref: tut-text-regex
+ title: Resposta de texto em linha
+ text: |
+ Bla bla bla
+ correct: '(VERDE|[Vv]erde)'
```
Neste exemplo a expressão regular é `(VERDE|[Vv]erde)`.
- correct: '(VERDE|[Vv]erde)'
+ ---
+
+ **Atenção:** A expressão regular deve seguir as convenções da suportadas em
+ python (ver
+ [Regular expression operations](https://docs.python.org/3/library/re.html)).
+ Em particular, a expressão regular acima também aceita a resposta
+ `verde, azul`.
+ Possivelmente devia marcar-se o final com o cifrão `(VERDE|[Vv]erde)$`.
+
+ correct: '(VERDE|[Vv]erde)'
# ---------------------------------------------------------------------------
- type: numeric-interval
@@ -289,23 +299,27 @@
title: Resposta numérica em linha de texto
text: |
Este tipo de perguntas esperam uma resposta numérica (vírgula flutuante).
- O resultado é considerado correcto se estiver dentro do intervalo (fechado)
+ O resultado é considerado correcto se estiver dentro do intervalo fechado
indicado.
```yaml
- - type: numeric-interval
- ref: tut-numeric-interval
- title: Resposta numérica em linha de texto
- text: |
- Bla bla bla
- correct: [3.14, 3.15]
+ - type: numeric-interval
+ ref: tut-numeric-interval
+ title: Resposta numérica em linha de texto
+ text: |
+ Escreva o número $\pi$ com pelo menos duas casa decimais.
+ correct: [3.14, 3.15]
```
Neste exemplo o intervalo de respostas correctas é [3.14, 3.15].
+
+ **Atenção:** as respostas têm de usar o ponto como separador decimal.
+ Em geral são aceites números inteiros, como `123`,
+ ou em vírgula flutuante, como em `0.23`, `1e-3`.
correct: [3.14, 3.15]
solution: |
- Um exemplo de uma resposta correcta é o número $\pi\approx 3.14159265359$.
-
+ Sabems que $\pi\approx 3.14159265359$.
+ Portanto, um exemplo de uma resposta correcta é `3.1416`.
# ---------------------------------------------------------------------------
- type: textarea
@@ -313,47 +327,61 @@
title: Resposta em múltiplas linhas de texto
text: |
Este tipo de perguntas permitem respostas em múltiplas linhas de texto, que
- podem ser úteis por exemplo para validar código.
- A resposta é enviada para ser avaliada por um programa externo (programa
- executável).
- O programa externo, recebe a resposta via stdin e devolve a classificação
- via stdout. Exemplo:
+ podem ser úteis por exemplo para introduzir código.
+
+ A resposta é enviada para um programa externo para ser avaliada.
+ O programa externo é um programa qualquer executável pelo sistema.
+ Este recebe a resposta submetida pelo aluno via `stdin` e devolve a
+ classificação via `stdout`.
+ Exemplo:
```yaml
- - type: textarea
- ref: tut-textarea
- title: Resposta em múltiplas linhas de texto
- text: |
- Bla bla bla
- correct: correct/correct-question.py
- lines: 3
- timeout: 5
+ - type: textarea
+ ref: tut-textarea
+ title: Resposta em múltiplas linhas de texto
+ text: |
+ Bla bla bla
+ correct: correct/correct-question.py # programa a executar
+ timeout: 5
```
Neste exemplo, o programa de avaliação é um script python que verifica se a
- resposta contém as três palavras red, green e blue, e calcula uma nota de
- 0.0 a 1.0.
- O programa externo pode ser escrito em qualquer linguagem e a interacção
- com o servidor faz-se via stdin/stdout.
- Se o programa externo demorar mais do que o `timout` indicado, é
- automaticamente cancelado e é atribuída a classificação de 0.0 valores.
- `lines: 3` é a dimensão inicial da caixa de texto (pode depois ser
- redimensionada pelo aluno).
-
- O programa externo deve atribuir uma classificação entre 0.0 e 1.0. Pode
- simplesmente fazer print da classificação como um número, ou opcionalmente escrever em formato yaml eventualmente com um comentário. Exemplo:
+ resposta contém as três palavras red, green e blue, e calcula uma nota no
+ intervalo 0.0 a 1.0.
+ O programa externo é um programa executável no sistema, escrito em
+ qualquer linguagem de programação. A interacção com o servidor faz-se
+ sempre via stdin/stdout.
+
+ Se o programa externo exceder o `timeout` indicado (em segundos),
+ é automaticamente cancelado e é atribuída a classificação de 0.0 valores.
+
+ Após terminar a correcção, o programa externo deve enviar a classificação
+ para o stdout.
+ Pode simplesmente fazer `print` da classificação como um número em vírgula
+ flutuante, por exemplo
```yaml
- grade: 0.5
- comments: A resposta correcta é "red green blue".
+ 0.75
+ ```
+
+ ou opcionalmente escrever em formato yaml, eventualmente com um comentário
+ que será arquivado com o teste.
+ Exemplo:
+
+ ```yaml
+ grade: 0.5
+ comments: |
+ Esqueceu-se de algumas cores.
+ A resposta correcta era `red green blue`.
```
O comentário é mostrado na revisão de prova.
+ answer: |
+ Esta caixa aumenta de tamanho automaticamente e
+ pode estar previamente preenchida (use answer: texto).
correct: correct/correct-question.py
- lines: 3
timeout: 5
-
# ---------------------------------------------------------------------------
- type: information
ref: tut-information
@@ -361,37 +389,39 @@
text: |
As perguntas deste tipo não contam para avaliação. O objectivo é fornecer
instruções para os alunos, por exemplo tabelas para consulta, fórmulas, etc.
- Nesta como em todos os tipos de perguntas pode escrever-se fórmulas em
- LaTeX. Exemplo:
-
- ```yaml
- - type: information
- ref: tut-information
- title: Texto informativo
- text: |
- A distribuição gaussiana $\mathcal{N}(x\mid\mu,\sigma^2)$ é
- definida por
-
- $$
- p(x) = \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\tfrac{1}{2}\tfrac{(x-\mu)^2}{\sigma^2}}.
- $$
- ```
+ Nesta, tal como em todos os tipos de perguntas podem escrever-se fórmulas
+ em LaTeX. Exemplo:
- Produz:
-
- A distribuição gaussiana $\mathcal{N}(x\mid\mu,\sigma^2)$ é definida por
+ A distribuição gaussiana $\mathcal{N}(x\mid\mu,\sigma^2)$ é
+ definida pela função densidade de probabilidade
$$
- p(x) = \frac{1}{\sqrt{2\pi\sigma^2}}e^{-\tfrac{1}{2}\tfrac{(x-\mu)^2}{\sigma^2}}.
+ p(x) = \frac{1}{\sqrt{2\pi\sigma^2}}
+ \exp\Big({-\frac{(x-\mu)^2}{2\sigma^2}}\Big).
$$
+ ---
+
+ ```yaml
+ - type: information
+ ref: tut-information
+ title: Texto informativo
+ text: |
+ A distribuição gaussiana $\mathcal{N}(x\mid\mu,\sigma^2)$ é
+ definida pela função densidade de probabilidade
+
+ $$
+ p(x) = \frac{1}{\sqrt{2\pi\sigma^2}}
+ \exp\Big({-\frac{(x-\mu)^2}{2\sigma^2}}\Big).
+ $$
+ ```
# ---------------------------------------------------------------------------
- type: success
ref: tut-success
title: Texto informativo (sucesso)
text: |
- Também não conta para avaliação.
+ Também não conta para avaliação. É apenas o aspecto gráfico que muda.
Além das fórmulas LaTeX, também se pode escrever troços de código:
@@ -402,16 +432,16 @@
}
```
- Faz-se assim:
+ ---
- type: success
ref: tut-success
title: Texto informativo (sucesso)
text: |
Também não conta para avaliação.
+ É apenas o aspecto gráfico que muda.
- Já vimos como se introduzem fórmulas LaTeX, também se pode escrever
- troços de código:
+ Além das fórmulas LaTeX, também se pode escrever troços de código:
```C
int main() {
@@ -420,7 +450,6 @@
}
```
-
# ---------------------------------------------------------------------------
- type: warning
ref: tut-warning
@@ -430,13 +459,15 @@
Neste exemplo mostramos como se pode construir uma tabela como a seguinte:
- Left | Center | Right
- -----------------|:-------------:|----------:
- $\sin(x^2)$ | *hello* | $1600.00
- $\frac{1}{2\pi}$ | **world** | $12.50
- $\sqrt{\pi}$ | `code` | $1.99
+ Left | Center | Right
+ -----------------|:----------------:|----------:
+ *hello* | $\sin(x^2)$ | $1600.00
+ **world** | $\frac{1}{2\pi}$ | $12.50
+ `code` | $\sqrt{\pi}$ | $1.99
- As tabelas podem conter Markdown e LaTeX. Faz-se assim:
+ As tabelas podem conter Markdown e LaTeX.
+
+ ---
```yaml
- type: warning
@@ -445,23 +476,19 @@
text: |
Bla bla bla
- Left | Center | Right
- -----------------|:-------------:|----------:
- $\sin(x^2)$ | *hello* | $1600.00
- $\frac{1}{2\pi}$ | **world** | $12.50
- $\sqrt{\pi}$ | `code` | $1.99
+ Left | Center | Right
+ -----------------|:----------------:|----------:
+ *hello* | $\sin(x^2)$ | $1600.00
+ **world** | $\frac{1}{2\pi}$ | $12.50
+ `code` | $\sqrt{\pi}$ | $1.99
```
- A linha de separação entre o cabeçalho e o corpo da tabela indica o
- alinhamento da coluna com os sinais de dois-pontos.
-
-
# ----------------------------------------------------------------------------
- type: alert
ref: tut-alert
title: Texto informativo (perigo)
text: |
- Texto importante. Não conta para avaliação.
+ Não conta para avaliação. Texto importante.

@@ -473,5 +500,8 @@
- Imagens centradas com título: ``.
O título aprece por baixo da imagem. O título pode ser uma string vazia.
+# ----------------------------------------------------------------------------
+- type: information
+ text: This question is not included in the test and will not show up.
# ----------------------------------------------------------------------------
diff --git a/demo/tutorial.yaml b/demo/tutorial.yaml
deleted file mode 100644
index 027d72e..0000000
--- a/demo/tutorial.yaml
+++ /dev/null
@@ -1,57 +0,0 @@
----
-# ============================================================================
-# The test reference should be a unique identifier. It is saved in the database
-# so that queries for the results can be done in the terminal with
-# $ sqlite3 students.db "select * from tests where ref='demo'"
-ref: tutorial
-
-# (optional, default: '') You may wish to refer the course, year or kind of test
-title: Teste de demonstração (tutorial)
-
-# (optional) duration in minutes, 0 or undefined is infinite
-duration: 120
-
-# Database with student credentials and grades of all questions and tests done
-# The database is an sqlite3 file generate with the script initdb.py
-database: students.db
-
-# Generate a file for each test done by a student.
-# It includes the questions, answers and grades.
-answers_dir: ans
-
-# (optional, default: False) Show points for each question, scale 0-20.
-show_points: true
-# scale_points: true
-# scale_max: 20
-
-# ----------------------------------------------------------------------------
-# Base path applied to the questions files and all the scripts
-# including question generators and correctors.
-# Either absolute path or relative to current directory can be used.
-questions_dir: .
-
-# (optional) List of files containing questions in yaml format.
-# Selected questions will be obtained from these files.
-# If undefined, all yaml files in questions_dir are loaded (not recommended).
-files:
- - questions/questions-tutorial.yaml
-
-# This is the list of questions that will make up the test.
-# The order is preserved.
-# There are several ways to define each question (explained below).
-questions:
- - tut-test
- - tut-questions
-
- - tut-radio
- - tut-checkbox
- - tut-text
- - tut-text-regex
- - tut-numeric-interval
- - ref: tut-textarea
- points: 2.0
-
- - tut-information
- - tut-success
- - tut-warning
- - tut-alert
diff --git a/perguntations/app.py b/perguntations/app.py
index 6dc4583..c3fd08f 100644
--- a/perguntations/app.py
+++ b/perguntations/app.py
@@ -71,6 +71,7 @@ class App(object):
testconf.update(conf) # configuration overrides from command line
# start test factory
+ logger.info(f'Creating test factory.')
try:
self.testfactory = TestFactory(testconf)
except TestFactoryException:
@@ -147,7 +148,7 @@ class App(object):
student_id = self.online[uid]['student'] # {number, name}
test = await self.testfactory.generate(student_id)
self.online[uid]['test'] = test
- logger.debug(f'Student {uid}: test is ready.')
+ logger.info(f'Student {uid}: test is ready.')
return self.online[uid]['test']
else:
# this implies an error in the code. should never be here!
@@ -158,19 +159,24 @@ class App(object):
# for example: {0:'hello', 1:[1,2]}
async def correct_test(self, uid, ans):
t = self.online[uid]['test']
+
+ # --- submit answers and correct test
t.update_answers(ans)
+ logger.info(f'Student {uid}: {len(ans)} answers submitted.')
+
grade = await t.correct()
+ logger.info(f'Student {uid}: grade = {grade} points.')
- # save test in JSON format
- fields = (t['student']['number'], t['ref'], str(t['finish_time']))
+ # --- save test in JSON format
+ fields = (uid, t['ref'], str(t['finish_time']))
fname = ' -- '.join(fields) + '.json'
fpath = path.join(t['answers_dir'], fname)
with open(path.expanduser(fpath), 'w') as f:
- # default=str required for datetime objects:
+ # default=str required for datetime objects
json.dump(t, f, indent=2, default=str)
- logger.info(f'Student {t["student"]["number"]}: saved JSON file.')
+ logger.info(f'Student {uid}: saved JSON.')
- # insert test and questions into database
+ # --- insert test and questions into database
with self.db_session() as s:
s.add(Test(
ref=t['ref'],
@@ -179,7 +185,7 @@ class App(object):
starttime=str(t['start_time']),
finishtime=str(t['finish_time']),
filename=fpath,
- student_id=t['student']['number'],
+ student_id=uid,
state=t['state'],
comment=''))
s.add_all([Question(
@@ -187,10 +193,10 @@ class App(object):
grade=q['grade'],
starttime=str(t['start_time']),
finishtime=str(t['finish_time']),
- student_id=t['student']['number'],
+ student_id=uid,
test_id=t['ref']) for q in t['questions'] if 'grade' in q])
- logger.info(f'Student {uid}: finished test, grade = {grade}.')
+ logger.info(f'Student {uid}: database updated.')
return grade
# -----------------------------------------------------------------------
diff --git a/perguntations/factory.py b/perguntations/factory.py
deleted file mode 100644
index 59fa90c..0000000
--- a/perguntations/factory.py
+++ /dev/null
@@ -1,173 +0,0 @@
-# We start with an empty QuestionFactory() that will be populated with
-# question generators that we can load from YAML files.
-# To generate an instance of a question we use the method generate(ref) where
-# the argument is the reference of the question we wish to produce.
-#
-# Example:
-#
-# # read everything from question files
-# factory = QuestionFactory()
-# factory.load_files(['file1.yaml', 'file1.yaml'], '/path/to')
-#
-# question = factory.generate('some_ref')
-#
-# # experiment answering one question and correct it
-# question['answer'] = 42 # insert answer
-# grade = question.correct() # correct answer
-
-# An instance of an actual question is an object that inherits from Question()
-#
-# Question - base class inherited by other classes
-# QuestionInformation - not a question, just a box with content
-# QuestionRadio - single choice from a list of options
-# QuestionCheckbox - multiple choice, equivalent to multiple true/false
-# QuestionText - line of text compared to a list of acceptable answers
-# QuestionTextRegex - line of text matched against a regular expression
-# QuestionTextArea - corrected by an external program
-# QuestionNumericInterval - line of text parsed as a float
-
-# python standard library
-from os import path
-import logging
-
-# this project
-from perguntations.tools import load_yaml, run_script
-from perguntations.questions import (QuestionRadio, QuestionCheckbox,
- QuestionText, QuestionTextRegex,
- QuestionNumericInterval, QuestionTextArea,
- QuestionInformation)
-
-# setup logger for this module
-logger = logging.getLogger(__name__)
-
-
-# ============================================================================
-class QuestionFactoryException(Exception):
- pass
-
-
-# ============================================================================
-# This class contains a pool of questions generators from which particular
-# Question() instances are generated using QuestionsFactory.generate(ref).
-# ============================================================================
-class QuestionFactory(dict):
- _types = {
- 'radio': QuestionRadio,
- 'checkbox': QuestionCheckbox,
- 'text': QuestionText,
- 'text-regex': QuestionTextRegex,
- 'numeric-interval': QuestionNumericInterval,
- 'textarea': QuestionTextArea,
- # -- informative panels --
- 'information': QuestionInformation,
- 'success': QuestionInformation,
- 'warning': QuestionInformation,
- 'alert': QuestionInformation
- }
-
- # ------------------------------------------------------------------------
- def __init__(self):
- super().__init__()
-
- # ------------------------------------------------------------------------
- # Add single question provided in a dictionary.
- # After this, each question will have at least 'ref' and 'type' keys.
- # ------------------------------------------------------------------------
- def add_question(self, question):
- q = question
-
- # if missing defaults to ref='/path/file.yaml:3'
- q.setdefault('ref', f'{q["filename"]}:{q["index"]}')
-
- if q['ref'] in self:
- logger.error(f'Duplicate reference "{q["ref"]}".')
-
- q.setdefault('type', 'information')
-
- self[q['ref']] = q
- logger.debug(f'Added question "{q["ref"]}" to the pool.')
-
- # ------------------------------------------------------------------------
- # load single YAML questions file
- # ------------------------------------------------------------------------
- def load_file(self, pathfile, questions_dir=''):
- # questions_dir is a base directory
- # pathfile is a path of a file under the questions_dir
- # For example, if
- # pathfile = 'math/questions.yaml'
- # questions_dir = '/home/john/questions'
- # then the complete path is
- # fullpath = '/home/john/questions/math/questions.yaml'
- fullpath = path.normpath(path.join(questions_dir, pathfile))
- (dirname, filename) = path.split(fullpath)
-
- questions = load_yaml(fullpath, default=[])
-
- for i, q in enumerate(questions):
- try:
- q.update({
- 'filename': filename,
- 'path': dirname,
- 'index': i # position in the file, 0 based
- })
- except AttributeError:
- logger.error(f'Question {pathfile}:{i} not a dictionary!')
- else:
- self.add_question(q)
-
- logger.info(f'{len(questions):>4} from "{pathfile}"')
-
- # ------------------------------------------------------------------------
- # load multiple YAML question files
- # ------------------------------------------------------------------------
- def load_files(self, files, questions_dir):
- logger.info(f'Loading questions from files in "{questions_dir}":')
- for filename in files:
- self.load_file(filename, questions_dir)
-
- # ------------------------------------------------------------------------
- # Given a ref returns an instance of a descendent of Question(),
- # i.e. a question object (radio, checkbox, ...).
- # ------------------------------------------------------------------------
- def generate(self, ref):
-
- # Shallow copy so that script generated questions will not replace
- # the original generators
- try:
- q = self[ref].copy()
- except KeyError: # FIXME exception type?
- logger.error(f'Can\'t find question "{ref}".')
- raise QuestionFactoryException()
-
- # If question is of generator type, an external program will be run
- # which will print a valid question in yaml format to stdout. This
- # output is then converted to a dictionary and `q` becomes that dict.
- if q['type'] == 'generator':
- logger.debug(f'Generating "{ref}" from {q["script"]}')
- q.setdefault('args', []) # optional arguments
- q.setdefault('stdin', '') # FIXME necessary?
- script = path.join(q['path'], q['script'])
- out = run_script(script=script, args=q['args'], stdin=q['stdin'])
- try:
- q.update(out)
- except Exception:
- q.update({
- 'type': 'alert',
- 'title': 'Erro interno',
- 'text': 'Ocorreu um erro a gerar esta pergunta.'
- })
-
- # The generator was replaced by a question but not yet instantiated
-
- # Finally we create an instance of Question()
- try:
- qinstance = self._types[q['type']](q) # instance of correct class
- except KeyError:
- logger.error(f'Unknown type {q["type"]} in {q["filename"]}:{ref}.')
- raise
- except Exception:
- logger.error(f'Failed to create question {q["filename"]}:{ref}.')
- raise
- else:
- logger.debug(f'Generated question "{ref}".')
- return qinstance
diff --git a/perguntations/models.py b/perguntations/models.py
index 6ebd531..58e96f1 100644
--- a/perguntations/models.py
+++ b/perguntations/models.py
@@ -34,7 +34,7 @@ class Test(Base):
ref = Column(String)
title = Column(String) # FIXME depends on ref and should come from another table...
grade = Column(Float)
- state = Column(String) # ONGOING, FINISHED, QUIT, NULL
+ state = Column(String) # ACTIVE, FINISHED, QUIT, NULL
comment = Column(String)
starttime = Column(String)
finishtime = Column(String)
diff --git a/perguntations/questions.py b/perguntations/questions.py
index c88c186..8ac5bec 100644
--- a/perguntations/questions.py
+++ b/perguntations/questions.py
@@ -5,10 +5,10 @@ import re
from os import path
import logging
from typing import Any, Dict, NewType
-# import uuid
+import uuid
# this project
-from perguntations.tools import run_script, run_script_async
+from .tools import run_script, run_script_async
# setup logger for this module
logger = logging.getLogger(__name__)
@@ -71,17 +71,18 @@ class QuestionRadio(Question):
'''
# ------------------------------------------------------------------------
+ # FIXME marking all options right breaks
def __init__(self, q: QDict) -> None:
super().__init__(q)
n = len(self['options'])
- # set defaults if missing
self.set_defaults(QDict({
'text': '',
'correct': 0,
'shuffle': True,
'discount': True,
+ 'max_tries': (n + 3) // 4 # 1 try for each 4 options
}))
# convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0]
@@ -97,11 +98,11 @@ class QuestionRadio(Question):
raise QuestionException(msg)
if self['shuffle']:
- # separate right from wrong options
+ # lists with indices of right and wrong options
right = [i for i in range(n) if self['correct'][i] >= 1]
wrong = [i for i in range(n) if self['correct'][i] < 1]
- self.set_defaults({'choose': 1+len(wrong)})
+ self.set_defaults(QDict({'choose': 1+len(wrong)}))
# try to choose 1 correct option
if right:
@@ -124,7 +125,7 @@ class QuestionRadio(Question):
self['correct'] = [float(correct[i]) for i in perm]
# ------------------------------------------------------------------------
- # can return negative values for wrong answers
+ # can assign negative grades for wrong answers
def correct(self) -> None:
super().correct()
@@ -157,13 +158,14 @@ class QuestionCheckbox(Question):
n = len(self['options'])
# set defaults if missing
- self.set_defaults({
+ self.set_defaults(QDict({
'text': '',
- 'correct': [1.0] * n, # Using 0.0 breaks the (right, wrong) opts
+ 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options
'shuffle': True,
'discount': True,
- 'choose': n, # number of options
- })
+ 'choose': n, # number of options
+ 'max_tries': max(1, min(n - 1, 3))
+ }))
if len(self['correct']) != n:
msg = (f'Options and correct size mismatch in '
@@ -172,23 +174,26 @@ class QuestionCheckbox(Question):
raise QuestionException(msg)
# if an option is a list of (right, wrong), pick one
- # FIXME it's possible that all options are chosen wrong
options = []
correct = []
for o, c in zip(self['options'], self['correct']):
if isinstance(o, list):
r = random.randint(0, 1)
o = o[r]
- c = c if r == 0 else -c
+ if r == 1:
+ c = -c
options.append(str(o))
correct.append(float(c))
# generate random permutation, e.g. [2,1,4,0,3]
# and apply to `options` and `correct`
if self['shuffle']:
- perm = random.sample(range(n), self['choose'])
+ perm = random.sample(range(n), k=self['choose'])
self['options'] = [options[i] for i in perm]
self['correct'] = [correct[i] for i in perm]
+ else:
+ self['options'] = options[:self['choose']]
+ self['correct'] = correct[:self['choose']]
# ------------------------------------------------------------------------
# can return negative values for wrong answers
@@ -213,7 +218,7 @@ class QuestionCheckbox(Question):
self['grade'] = x / sum_abs
-# ============================================================================
+# ===========================================================================
class QuestionText(Question):
'''An instance of QuestionText will always have the keys:
type (str)
@@ -226,10 +231,10 @@ class QuestionText(Question):
def __init__(self, q: QDict) -> None:
super().__init__(q)
- self.set_defaults({
+ self.set_defaults(QDict({
'text': '',
'correct': [],
- })
+ }))
# make sure its always a list of possible correct answers
if not isinstance(self['correct'], list):
@@ -239,7 +244,6 @@ class QuestionText(Question):
self['correct'] = [str(a) for a in self['correct']]
# ------------------------------------------------------------------------
- # can return negative values for wrong answers
def correct(self) -> None:
super().correct()
@@ -260,13 +264,12 @@ class QuestionTextRegex(Question):
def __init__(self, q: QDict) -> None:
super().__init__(q)
- self.set_defaults({
+ self.set_defaults(QDict({
'text': '',
'correct': '$.^', # will always return false
- })
+ }))
# ------------------------------------------------------------------------
- # can return negative values for wrong answers
def correct(self) -> None:
super().correct()
if self['answer'] is not None:
@@ -298,7 +301,6 @@ class QuestionNumericInterval(Question):
}))
# ------------------------------------------------------------------------
- # can return negative values for wrong answers
def correct(self) -> None:
super().correct()
if self['answer'] is not None:
@@ -307,7 +309,8 @@ class QuestionNumericInterval(Question):
try: # replace , by . and convert to float
answer = float(self['answer'].replace(',', '.', 1))
except ValueError:
- self['comments'] = 'A resposta não é numérica.'
+ self['comments'] = ('A resposta tem de ser numérica, '
+ 'por exemplo `12.345`.')
self['grade'] = 0.0
else:
self['grade'] = 1.0 if lower <= answer <= upper else 0.0
@@ -326,12 +329,12 @@ class QuestionTextArea(Question):
def __init__(self, q: QDict) -> None:
super().__init__(q)
- self.set_defaults({
+ self.set_defaults(QDict({
'text': '',
'timeout': 5, # seconds
'correct': '', # trying to execute this will fail => grade 0.0
'args': []
- })
+ }))
self['correct'] = path.join(self['path'], self['correct'])
@@ -396,11 +399,6 @@ class QuestionTextArea(Question):
# ===========================================================================
class QuestionInformation(Question):
- '''An instance of QuestionInformation will always have the keys:
- type (str)
- text (str)
- points (0.0)
- '''
# ------------------------------------------------------------------------
def __init__(self, q: QDict) -> None:
super().__init__(q)
@@ -412,3 +410,115 @@ class QuestionInformation(Question):
def correct(self) -> None:
super().correct()
self['grade'] = 1.0 # always "correct" but points should be zero!
+
+
+# ===========================================================================
+# QFactory is a class that can generate question instances, e.g. by shuffling
+# options, running a script to generate the question, etc.
+#
+# To generate an instance of a question we use the method generate() where
+# the argument is the reference of the question we wish to produce.
+# The generate() method returns a question instance of the correct class.
+#
+# Example:
+#
+# # generate a question instance from a dictionary
+# qdict = {
+# 'type': 'radio',
+# 'text': 'Choose one',
+# 'options': ['a', 'b']
+# }
+# qfactory = QFactory(qdict)
+# question = qfactory.generate()
+#
+# # answer one question and correct it
+# question['answer'] = 42 # set answer
+# question.correct() # correct answer
+# grade = question['grade'] # get grade
+# ===========================================================================
+class QFactory(object):
+ # Depending on the type of question, a different question class will be
+ # instantiated. All these classes derive from the base class `Question`.
+ _types = {
+ 'radio': QuestionRadio,
+ 'checkbox': QuestionCheckbox,
+ 'text': QuestionText,
+ 'text-regex': QuestionTextRegex,
+ 'numeric-interval': QuestionNumericInterval,
+ 'textarea': QuestionTextArea,
+ # -- informative panels --
+ 'information': QuestionInformation,
+ 'success': QuestionInformation,
+ 'warning': QuestionInformation,
+ 'alert': QuestionInformation,
+ }
+
+ def __init__(self, qdict: QDict = QDict({})) -> None:
+ self.question = qdict
+
+ # -----------------------------------------------------------------------
+ # Given a ref returns an instance of a descendent of Question(),
+ # i.e. a question object (radio, checkbox, ...).
+ # -----------------------------------------------------------------------
+ def generate(self) -> Question:
+ logger.debug(f'[QFactory.generate] "{self.question["ref"]}"...')
+ # Shallow copy so that script generated questions will not replace
+ # the original generators
+ q = self.question.copy()
+ q['qid'] = str(uuid.uuid4()) # unique for each generated question
+
+ # If question is of generator type, an external program will be run
+ # which will print a valid question in yaml format to stdout. This
+ # output is then yaml parsed into a dictionary `q`.
+ if q['type'] == 'generator':
+ logger.debug(f' \\_ Running "{q["script"]}".')
+ q.setdefault('args', [])
+ q.setdefault('stdin', '') # FIXME is it really necessary?
+ script = path.join(q['path'], q['script'])
+ out = run_script(script=script, args=q['args'], stdin=q['stdin'])
+ q.update(out)
+
+ # Finally we create an instance of Question()
+ try:
+ qinstance = self._types[q['type']](QDict(q)) # of matching class
+ except QuestionException as e:
+ logger.error(e)
+ raise e
+ except KeyError:
+ logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
+ raise
+ else:
+ return qinstance
+
+ # -----------------------------------------------------------------------
+ async def generate_async(self) -> Question:
+ logger.debug(f'[QFactory.generate_async] "{self.question["ref"]}"...')
+ # Shallow copy so that script generated questions will not replace
+ # the original generators
+ q = self.question.copy()
+ q['qid'] = str(uuid.uuid4()) # unique for each generated question
+
+ # If question is of generator type, an external program will be run
+ # which will print a valid question in yaml format to stdout. This
+ # output is then yaml parsed into a dictionary `q`.
+ if q['type'] == 'generator':
+ logger.debug(f' \\_ Running "{q["script"]}".')
+ q.setdefault('args', [])
+ q.setdefault('stdin', '') # FIXME is it really necessary?
+ script = path.join(q['path'], q['script'])
+ out = await run_script_async(script=script, args=q['args'],
+ stdin=q['stdin'])
+ q.update(out)
+
+ # Finally we create an instance of Question()
+ try:
+ qinstance = self._types[q['type']](QDict(q)) # of matching class
+ except QuestionException as e:
+ logger.error(e)
+ raise e
+ except KeyError:
+ logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
+ raise
+ else:
+ logger.debug(f'[generate_async] Done instance of {q["ref"]}')
+ return qinstance
diff --git a/perguntations/templates/question-textarea.html b/perguntations/templates/question-textarea.html
index 6e26e91..1216286 100644
--- a/perguntations/templates/question-textarea.html
+++ b/perguntations/templates/question-textarea.html
@@ -2,6 +2,6 @@
{% block answer %}
-
+
{% end %}
diff --git a/perguntations/test.py b/perguntations/test.py
index 52d9a4b..704bad7 100644
--- a/perguntations/test.py
+++ b/perguntations/test.py
@@ -5,10 +5,10 @@ import fnmatch
import random
from datetime import datetime
import logging
-import asyncio
# this project
-from perguntations.factory import QuestionFactory
+from perguntations.questions import QFactory
+from perguntations.tools import load_yaml
# Logger configuration
logger = logging.getLogger(__name__)
@@ -25,72 +25,101 @@ class TestFactoryException(Exception):
# instances of TestFactory(), one for each test.
# ===========================================================================
class TestFactory(dict):
- _defaults = {
- 'title': '',
- 'show_points': False,
- 'scale_points': True,
- 'scale_max': 20.0,
- 'duration': 0,
- # debug options:
- 'debug': False,
- 'show_ref': False
- }
-
# -----------------------------------------------------------------------
- # loads configuration from yaml file, then updates (overriding)
- # some configurations using the conf argument.
- # base questions are loaded from files into a pool.
+ # Loads configuration from yaml file, then overrides some configurations
+ # using the conf argument.
+ # Base questions are added to a pool of questions factories.
# -----------------------------------------------------------------------
def __init__(self, conf):
- super().__init__(conf)
-
- # --- defaults for optional keys
- for k, v in self._defaults.items():
- self.setdefault(k, v)
-
- # set defaults and sanity checks
+ # --- set test configutation and defaults
+ super().__init__({ # defaults
+ 'title': '',
+ 'show_points': False,
+ 'scale_points': True,
+ 'scale_max': 20.0,
+ 'duration': 0, # infinite
+ 'debug': False,
+ 'show_ref': False
+ })
+ self.update(conf)
+
+ # --- perform sanity checks and normalize the test questions
try:
self.sanity_checks()
except TestFactoryException:
logger.critical('Failed sanity checks in test factory.')
raise
- if conf['review']:
- logger.info('Review mode. No questions loaded.')
+ # --- for review, we are done. no factories needed
+ if self['review']:
+ logger.info('Review mode. No questions loaded. No factories.')
return
- # loads yaml files to question_factory
- self.question_factory = QuestionFactory()
- self.question_factory.load_files(files=self['files'],
- questions_dir=self['questions_dir'])
-
- # check if all questions exist ('ref' keys are correct?)
- logger.info('Checking questions for errors:')
- nerrs = 0
- i = 0
- for q in self['questions']:
- for r in q['ref']:
- i += 1
- try:
- self.question_factory.generate(r)
- except Exception:
- logger.error(f'Failed to generate "{r}".')
- nerrs += 1
- else:
- logger.info(f'{i:4}. "{r}" Ok.')
-
- if nerrs > 0:
- logger.critical(f'Found {nerrs} errors generating questions.')
+ # --- find all questions in the test that need a factory
+ qrefs = [r for q in self['questions'] for r in q['ref']]
+ logger.debug(f'[TestFactory.__init__] test has {len(qrefs)} questions')
+
+ # --- load and build question factories
+ self.question_factory = {}
+
+ n, nerr = 0, 0
+ for file in self["files"]:
+ fullpath = path.normpath(path.join(self["questions_dir"], file))
+ (dirname, filename) = path.split(fullpath)
+
+ questions = load_yaml(fullpath, default=[])
+
+ for i, q in enumerate(questions):
+ # make sure every question in the file is a dictionary
+ if not isinstance(q, dict):
+ logger.critical(f'Question {file}:{i} not a dictionary!')
+ raise TestFactoryException()
+
+ # if ref is missing, then set to '/path/file.yaml:3'
+ q.setdefault('ref', f'{file}:{i}')
+
+ # check for duplicate refs
+ if q['ref'] in self.question_factory:
+ other = self.question_factory[q['ref']]
+ otherfile = path.join(other['path'], other['filename'])
+ logger.critical(f'Duplicate reference {q["ref"]} in files '
+ f'{otherfile} and {fullpath}.')
+ raise TestFactoryException()
+
+ # make factory only for the questions used in the test
+ if q['ref'] in qrefs:
+ q.update({
+ 'filename': filename,
+ 'path': dirname,
+ 'index': i # position in the file, 0 based
+ })
+
+ q.setdefault('type', 'information')
+
+ self.question_factory[q['ref']] = QFactory(q)
+ logger.debug(f'[TestFactory.__init__] QFactory: "{q["ref"]}".')
+
+ # check if all the questions can be correctly generated
+ try:
+ self.question_factory[q['ref']].generate()
+ except Exception:
+ logger.error(f'Failed to generate "{q["ref"]}".')
+ nerr += 1
+ else:
+ logger.info(f'{n:4}. "{q["ref"]}" Ok.')
+ n += 1
+
+ if nerr > 0:
+ logger.critical(f'Found {nerr} errors generating questions.')
raise TestFactoryException()
else:
- logger.info(f'No errors found. Factory ready for "{self["ref"]}".')
+ logger.info(f'No errors found. Ready for "{self["ref"]}".')
- # -----------------------------------------------------------------------
+ # ------------------------------------------------------------------------
# Checks for valid keys and sets default values.
# Also checks if some files and directories exist
- # -----------------------------------------------------------------------
+ # ------------------------------------------------------------------------
def sanity_checks(self):
-
# --- database
if 'database' not in self:
logger.critical('Missing "database" in configuration.')
@@ -147,7 +176,6 @@ class TestFactory(dict):
logger.critical(f'Missing "questions" in {self["filename"]}.')
raise TestFactoryException()
- # normalize questions to a list of dictionaries
for i, q in enumerate(self['questions']):
# normalize question to a dict and ref to a list of references
if isinstance(q, str):
@@ -157,50 +185,57 @@ class TestFactory(dict):
self['questions'][i] = q
- # -----------------------------------------------------------------------
- # Given a dictionary with a student id {'name':'john', 'number': 123}
+ # ------------------------------------------------------------------------
+ # Given a dictionary with a student dict {'name':'john', 'number': 123}
# returns instance of Test() for that particular student
- # -----------------------------------------------------------------------
+ # ------------------------------------------------------------------------
async def generate(self, student):
test = []
- total_points = 0.0
+ # total_points = 0.0
n = 1
- loop = asyncio.get_running_loop()
- qgenerator = self.question_factory.generate
+ nerr = 0
for qq in self['questions']:
- # generate Question() selected randomly from list of references
+ # choose one question variant
qref = random.choice(qq['ref'])
+ # generate instance of question
try:
- q = await loop.run_in_executor(None, qgenerator, qref)
+ q = await self.question_factory[qref].generate_async()
except Exception:
logger.error(f'Can\'t generate question "{qref}". Skipping.')
+ nerr += 1
continue
# some defaults
if q['type'] in ('information', 'success', 'warning', 'alert'):
- q['points'] = qq.get('points', 0.0)
+ q.setdefault('points', 0.0)
else:
- q['points'] = qq.get('points', 1.0)
- q['number'] = n
+ q.setdefault('points', 1.0)
+ q['number'] = n # counter for non informative panels
n += 1
- total_points += q['points']
test.append(q)
# normalize question points to scale
if self['scale_points']:
+ total_points = sum(q['points'] for q in test)
for q in test:
- q['points'] *= self['scale_max'] / total_points
+ try:
+ q['points'] *= self['scale_max'] / total_points
+ except ZeroDivisionError:
+ logger.warning('Total points in the test is 0.0!!!')
+ q['points'] = 0.0
+
+ if nerr > 0:
+ logger.error(f'{nerr} errors found!')
return Test({
'ref': self['ref'],
'title': self['title'], # title of the test
'student': student, # student id
- 'questions': test, # list of questions
+ 'questions': test, # list of Question instances
'answers_dir': self['answers_dir'],
-
'duration': self['duration'],
'show_points': self['show_points'],
'show_ref': self['show_ref'],
@@ -217,11 +252,7 @@ class TestFactory(dict):
# ===========================================================================
-# Each instance of the Test() class is a concrete test to be answered by
-# a single student. It must/will contain at least these keys:
-# start_time, finish_time, questions, grade [0,20]
-# Note: for the save_json() function other keys are required
-# Note: grades are rounded to 1 decimal point: 0.0 - 20.0
+# Each instance Test() is a concrete test of a single student.
# ===========================================================================
class Test(dict):
# -----------------------------------------------------------------------
@@ -229,41 +260,34 @@ class Test(dict):
super().__init__(d)
self['start_time'] = datetime.now()
self['finish_time'] = None
- self['state'] = 'ONGOING'
+ self['state'] = 'ACTIVE'
self['comment'] = ''
- logger.info(f'Student {self["student"]["number"]}: new test.')
# -----------------------------------------------------------------------
# Removes all answers from the test (clean)
- # def reset_answers(self):
- # for q in self['questions']:
- # q['answer'] = None
- # logger.info(f'Student {self["student"]["number"]}: answers cleared.')
+ def reset_answers(self):
+ for q in self['questions']:
+ q['answer'] = None
# -----------------------------------------------------------------------
- # Given a dictionary ans={index: 'some answer'} updates the
+ # Given a dictionary ans={'ref': 'some answer'} updates the
# answers of the test. Only affects questions referred.
def update_answers(self, ans):
for ref, answer in ans.items():
self['questions'][ref]['answer'] = answer
- logger.info(f'Student {self["student"]["number"]}: '
- f'{len(ans)} answers updated.')
# -----------------------------------------------------------------------
- # Corrects all the answers and computes the final grade
+ # Corrects all the answers of the test and computes the final grade
async def correct(self):
self['finish_time'] = datetime.now()
self['state'] = 'FINISHED'
grade = 0.0
for q in self['questions']:
await q.correct_async()
-
grade += q['grade'] * q['points']
- logger.debug(f'Correcting "{q["ref"]}": {q["grade"]*100.0} %')
+ logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%')
- self['grade'] = max(0, round(grade, 1)) # avoid negative grades
- logger.info(f'Student {self["student"]["number"]}: '
- f'{self["grade"]} points.')
+ self['grade'] = max(0, round(grade, 1)) # truncate negative grades
return self['grade']
# -----------------------------------------------------------------------
--
libgit2 0.21.2