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