From a80f0415f31d36abac43208a3474dd6227d3e9b7 Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Thu, 7 Nov 2019 22:56:27 +0000 Subject: [PATCH] Major changes: - fix and improve sanity checks - show discount points - add scale_min - add support for list of regular expressions in regex questions - improve documentation - improves exception handling in python - grades are no longer rounded in the database, but show rounded to 1 decimal place in the browser. --- BUGS.md | 12 ++++++++---- README.md | 12 ++++++------ demo/demo.yaml | 39 ++++++++++++++++++++++++--------------- demo/questions/questions-tutorial.yaml | 138 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------- package-lock.json | 6 +++--- package.json | 2 +- perguntations/app.py | 30 +++++++++++++++--------------- perguntations/questions.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------ perguntations/serve.py | 2 +- perguntations/static/js/admin.js | 4 ++-- perguntations/templates/grade.html | 119 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------- perguntations/templates/question.html | 8 +++++++- perguntations/templates/review-question.html | 8 +++++++- perguntations/templates/review.html | 2 +- perguntations/templates/test.html | 4 +--- perguntations/test.py | 139 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------- perguntations/tools.py | 34 +++++++++++++++------------------- 17 files changed, 362 insertions(+), 323 deletions(-) diff --git a/BUGS.md b/BUGS.md index c1089a6..1c8a0a3 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,10 +1,9 @@ # BUGS +- na pagina grade.html as barras estao normalizadas para os limites scale_min e max do testte actual e nao do realizado. - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag que não preserva whitespace. Necessario adicionar
.
-- teste nao esta a mostrar imagens.
-- se houver erros a abrir ficheiros .yaml de perguntas, depois dos testes diz "No errors found".
-- dizer quanto desconta em cada pergunta de escolha multipla
+- teste nao esta a mostrar imagens de vez em quando.
 - ordenacao das notas em /admin nao é numerica, é ascii...
 - mensagems de erro do assembler aparecem na mesma linha na correcao e nao fazerm rendering do `$t`, ver se servidor faz parse do markdown dessas mensagens.
 - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente?
@@ -16,10 +15,11 @@ ou usar push (websockets?)
 - servidor nao esta a lidar com eventos scroll/resize. ignorar?
 - Test.reset_answers() unused.
 - mudar ref do test para test_id (ref já é usado nas perguntas)
+- incluir test_id na tabela questions (futuro semestre, pode quebrar compatibilidade).
 
 # TODO
 
-- nao esta a usar points das perguntas
+- testar as perguntas todas no início do teste.
 - test: mostrar duração do teste com progressbar no navbar.
 - submissao fazer um post ajax?
 - adicionar opcao para eliminar um teste em curso.
@@ -59,6 +59,10 @@ ou usar push (websockets?)
 
 # FIXED
 
+- dizer quanto desconta em cada pergunta de escolha multipla
+- se houver erros a abrir ficheiros .yaml de perguntas, depois dos testes diz "No errors found".
+- se faltarem files na especificação do teste, o check não detecta e factory não gera para essas perguntas.
+- nao esta a usar points das perguntas
 - quando se clica no texto de uma opcao, salta para outro lado na pagina.
 - suportar cotacao to teste diferente de 20 (e.g. para juntar perguntas em papel). opcao "points: 18" que normaliza total para 18 em vez de 20.
 - fazer package para instalar perguntations com pip.
diff --git a/README.md b/README.md
index 662af2d..438ccd6 100644
--- a/README.md
+++ b/README.md
@@ -1,11 +1,11 @@
 # User Manual
 
 1. [Requirements](#requirements)
-2. [Installation](#installation)
-3. [Setup](#setup)
-4. [Running a demo](#running-a-demo)
-5. [Running on lower ports](#running-on-lower-ports)
-6. [Troubleshooting](#troubleshooting)
+1. [Installation](#installation)
+1. [Setup](#setup)
+1. [Running a demo](#running-a-demo)
+1. [Running on lower ports](#running-on-lower-ports)
+1. [Troubleshooting](#troubleshooting)
 
 ---
 
@@ -260,7 +260,7 @@ Solutions:
 
 ---
 
-## Contribute
+## Please contribute
 
 - Writing questions in yaml format
 - Testing and reporting bugs
diff --git a/demo/demo.yaml b/demo/demo.yaml
index ed64539..3356dd4 100644
--- a/demo/demo.yaml
+++ b/demo/demo.yaml
@@ -1,28 +1,37 @@
 ---
 # ============================================================================
 # 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'"
+# so that queries can be done in the terminal like
+#     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
+# The database is an sqlite3 file generate with the command initdb
+database: ../demo/students.db
 
-# Generate a file for each test done by a student.
-# It includes the questions, answers and grades.
+# Directory where the tests including submitted answers and grades are stored.
+# The submitted tests and their corrections can be reviewed later.
 answers_dir: ans
 
-# (optional, default: False) Show points for each question, scale 0-20.
+# --- optional settings: -----------------------------------------------------
+
+# You may wish to refer the course, year or kind of test
+# (default: '')
+title: Teste de demonstração (tutorial)
+
+# Duration in minutes.
+# (0 or undefined means infinite time)
+duration: 60
+
+# Show points for each question, scale 0-20.
+# (default: false)
 show_points: true
-# scale_points: true
-# scale_max: 20
+
+# scale final grade to the interval [scale_min, scale_max]
+# (default: scale to [0,20])
+scale_points: true
+scale_max: 20
+scale_min: 0
 
 # ----------------------------------------------------------------------------
 # Base path applied to the questions files and all the scripts
diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml
index c210cf2..ea43cb2 100644
--- a/demo/questions/questions-tutorial.yaml
+++ b/demo/questions/questions-tutorial.yaml
@@ -17,15 +17,16 @@
     # --------------------------------------------------------------------------
     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
+    database: students.db      # base de dados previamente criada com initdb
+    answers_dir: ans           # directório onde ficam os testes dos alunos
 
     # 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)
+    duration: 60               # duração da prova em minutos (default: inf)
+    show_points: true          # mostra cotação das perguntas (default: true)
+    scale_points: true         # recalcula cotações para [scale_min, scale_max]
+    scale_max: 20              # limite superior da escala (default: 20)
+    scale_min: 0               # limite inferior da escala (default: 0)
+    debug: false               # mostra informação de debug no browser
 
     # --------------------------------------------------------------------------
     questions_dir: ~/topics    # raíz da árvore de directórios das perguntas
@@ -46,25 +47,24 @@
         points: 3.5
 
       - ref: pergunta2
-        points: 2
+        point: 2.0
 
-      - ref: tabela-auxiliar
+      # a cotação é 1.0 por defeito, se omitida
+      - ref: pergunta3
+
+      # uma string (não dict), é interpretada como referência
+      - tabela-auxiliar
 
       # escolhe aleatoriamente uma das variantes
       - ref: [pergunta3a, pergunta3b]
         points: 0.5
 
-      # a cotação é 1.0 por defeito (se omitida)
-      - ref: pergunta4
-
-      # se for uma string (não dict), é interpretada como referência
-      - pergunta5
     # --------------------------------------------------------------------------
     ```
 
-    A ordem das perguntas é mantida quando apresentada para o aluno.
+    A ordem das perguntas é mantida quando apresentada no teste.
 
-    O mesmo teste pode ser realizado várias vezes em vários turnos, não é
+    O mesmo teste pode ser realizado várias vezes em turnos diferentes, não é
     necessário alterar nada.
 
 # ----------------------------------------------------------------------------
@@ -73,14 +73,14 @@
   title: Especificação das perguntas
   text: |
     As perguntas estão definidas num ou mais ficheiros `yaml` como uma lista de
-    dicionários, onde cada pergunta é um dicionário.
+    perguntas, onde cada pergunta é um dicionário.
 
     Por exemplo, um ficheiro com o conteúdo abaixo contém duas perguntas, uma
     de escolha múltipla e outra apenas informativa:
 
     ```yaml
     ---
-    #-----------------------------------------------------------------------------
+    #---------------------------------------------------------------------------
     - type: radio
       ref: chave-unica-1
       text: Quanto é $1+1$?
@@ -89,7 +89,7 @@
         - 2
         - 3
 
-    #-----------------------------------------------------------------------------
+    #---------------------------------------------------------------------------
     - type: information
       ref: chave-unica-2
       text: |
@@ -98,13 +98,15 @@
         texto.
         É o caso desta pergunta.
 
-        O texto das perguntas é escrito em `markdown`.
+        O texto das perguntas é escrito em `markdown` e suporta fórmulas em
+        LaTeX.
 
-    #-----------------------------------------------------------------------------
+    #---------------------------------------------------------------------------
     ```
 
-    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.
+    As chaves são usadas para construir o teste e não se podem repetir, mesmo em
+    ficheiros diferentes.
+    De seguida mostram-se exemplos dos vários tipos de perguntas.
 
 # ----------------------------------------------------------------------------
 - type: radio
@@ -144,13 +146,14 @@
       choose: 3
     ```
 
-    Neste caso, será escolhida uma opção certa de entre o conjunto das certas
-    e duas erradas de entre o conjunto das erradas.
+    Neste caso, será escolhida uma opção certa e duas erradas.
     Os valores em `correct` representam o grau de correcção no intervalo [0, 1]
-    onde 1 representa 100% certo e 0 representa 0%.
+    onde 1 representa 100% certo e 0 representa 0%. Podem ser usados valores
+    entre 0 e 1, sendo atribuída a respectiva cotação, mas só o valor 1
+    representa uma opção certa.
 
     Por defeito, as opções são apresentadas por ordem aleatória.
-    Para manter a ordem acrescenta-se:
+    Para manter a ordem definida acrescenta-se:
 
     ```yaml
       shuffle: false
@@ -200,7 +203,7 @@
         - Opção 2
         - Opção 3 (certa)
         - Opção 4
-      correct: [1, -1, -1, 1, -1]
+      correct: [+1, -1, -1, +1, -1]
     ```
 
     Neste exemplo, seleccionando as opções 0 e 3 obtém-se cotação +1 em cada
@@ -209,10 +212,10 @@
     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.)*
+    *(Neste tipo de perguntas não há forma de responder a apenas algumas delas,
+    são sempre todas corrigidas. Se um aluno só sabe a resposta a algumas das
+    opções, deve ter cuidado porque as restantes também serão classificadas e
+    arrisca-se a ter cotação negativa)*
 
     Cada opção pode opcionalmente ser escrita como uma afirmação e o seu
     contrário, de maneira a aumentar a variabilidade dos textos.
@@ -282,6 +285,16 @@
 
     Neste exemplo a expressão regular é `(VERDE|[Vv]erde)`.
 
+    Também se pode dar uma lista de expressões regulares. Nesse caso a resposta
+    é considerada correcta se fizer match com alguma delas.
+    No exemplo acima, poder-se-ia ter usado uma lista
+
+    ```yaml
+      correct:
+        - 'VERDE'
+        - '[Vv]erde'
+    ```
+
     ---
 
     **Atenção:** A expressão regular deve seguir as convenções da suportadas em
@@ -289,7 +302,8 @@
     [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)$`.
+    Deve marcar-se o início e final `^(VERDE|[Vv]erde)$` para evitar estas
+    situações.
 
   correct: '(VERDE|[Vv]erde)'
 
@@ -311,7 +325,8 @@
       correct: [3.14, 3.15]
     ```
 
-    Neste exemplo o intervalo de respostas correctas é [3.14, 3.15].
+    Neste exemplo o intervalo de respostas correctas é o intervalo fechado
+    [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`,
@@ -349,9 +364,8 @@
     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 no
     intervalo 0.0 a 1.0.
-    O programa externo é executado num processo separado do sistema operativo.
-    Pode  escrito em qualquer linguagem de programação, desde que . A interacção com o servidor faz-se
-    sempre via stdin/stdout.
+    O programa externo é executado num processo separado e a interacção faz-se
+    via stdin/stdout.
 
     Se o programa externo exceder o `timeout` indicado (em segundos),
     este é automaticamente terminado e é atribuída a classificação de 0.0
@@ -424,9 +438,11 @@
   ref: tut-success
   title: Texto informativo (sucesso)
   text: |
-    Também não conta para avaliação. É apenas o aspecto gráfico que muda.
+    Não conta para avaliação. É apenas o aspecto gráfico que muda.
 
-    Além das fórmulas LaTeX, também se podem escrever troços de código:
+    Um pedaço de código em linha, por exemplo `x = sqrt(z)` é marcado com uma
+    fonte e cor diferente.
+    Também se podem escrever troços de código coloridos conforme a linguagem:
 
     ```C
     int main() {
@@ -437,21 +453,25 @@
 
     ---
 
-        - 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.
-
-            Além das fórmulas LaTeX, também se pode escrever troços de código:
-
-            ```C
-            int main() {
-                printf("Hello world!");
-                return 0;   // comentario
-            }
-            ```
+    ```yaml
+    - type: success
+      ref: tut-success
+      title: Texto informativo (sucesso)
+      text: |
+        Não conta para avaliação. É apenas o aspecto gráfico que muda.
+
+        Um pedaço de código em linha, por exemplo `x = sqrt(z)` é marcado com
+        uma fonte e cor diferente.
+        Também se podem escrever troços de código coloridos conforme a
+        linguagem:
+
+        ```C
+        int main() {
+            printf("Hello world!");
+            return 0;   // comentario
+        }
+        ```
+    ```
 
 # ---------------------------------------------------------------------------
 - type: warning
@@ -468,7 +488,8 @@
     **world**        | $\frac{1}{2\pi}$ |   $12.50
     `code`           | $\sqrt{\pi}$     |    $1.99
 
-    As tabelas podem conter Markdown e LaTeX.
+    As tabelas podem conter Markdown e LaTeX e permitem alinhamento das colunas,
+    mas o markdown é muito simples e não permite mais funcionalidades.
 
     ---
 
@@ -505,6 +526,11 @@
 
 # ----------------------------------------------------------------------------
 - type: information
-  text: This question is not included in the test and will not show up.
+  text: |
+    This question is not included in the test and will not shown up.
+    It also lacks a "ref" and is automatically named
+    `questions/questions-tutorial.yaml:0012`.
+    The number at the end is the index position of this question.
+    Indices start at 0.
 
 # ----------------------------------------------------------------------------
diff --git a/package-lock.json b/package-lock.json
index 954a6ae..14bf0d4 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13,9 +13,9 @@
       "integrity": "sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag=="
     },
     "codemirror": {
-      "version": "5.49.0",
-      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.0.tgz",
-      "integrity": "sha512-Hyzr0HToBdZpLBN9dYFO/KlJAsKH37/cXVHPAqa+imml0R92tb9AkmsvjnXL+SluEvjjdfkDgRjc65NG5jnMYA=="
+      "version": "5.49.2",
+      "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.2.tgz",
+      "integrity": "sha512-dwJ2HRPHm8w51WB5YTF9J7m6Z5dtkqbU9ntMZ1dqXyFB9IpjoUFDj80ahRVEoVanfIp6pfASJbOlbWdEf8FOzQ=="
     },
     "commander": {
       "version": "3.0.1",
diff --git a/package.json b/package.json
index c7423df..4b77f7c 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
     "dependencies": {
         "@fortawesome/fontawesome-free": "^5.11.2",
         "bootstrap": "^4.3",
-        "codemirror": "^5.49.0",
+        "codemirror": "^5.49.2",
         "datatables": "^1.10",
         "jquery": "^3.4.1",
         "mathjax": "^3",
diff --git a/perguntations/app.py b/perguntations/app.py
index c329f88..6aefac4 100644
--- a/perguntations/app.py
+++ b/perguntations/app.py
@@ -62,21 +62,21 @@ class App(object):
 
     # ------------------------------------------------------------------------
     def __init__(self, conf={}):
-        logger.info('Starting application.')
         self.online = dict()    # {uid: {'student':{...}, 'test': {...}}, ...}
         self.allowed = set([])  # '0' is hardcoded to allowed elsewhere
 
         logger.info(f'Loading test configuration "{conf["filename"]}".')
-        testconf = load_yaml(conf['filename'], default={})
-        testconf.update(conf)  # configuration overrides from command line
+        testconf = load_yaml(conf['filename'])
+        testconf.update(conf)  # command line options override configuration
 
         # start test factory
-        logger.info(f'Creating test factory.')
         try:
             self.testfactory = TestFactory(testconf)
-        except TestFactoryException:
-            logger.critical('Cannot create test factory.')
-            raise AppException()
+        except TestFactoryException as e:
+            logger.critical(e)
+            raise AppException('Failed to create test factory!')
+        else:
+            logger.info('No errors found. Test factory ready.')
 
         # connect to database and check registered students
         dbfile = self.testfactory['database']
@@ -87,8 +87,7 @@ class App(object):
             with self.db_session() as s:
                 n = s.query(Student).filter(Student.id != '0').count()
         except Exception:
-            logger.critical(f'Database unusable {dbfile}.')
-            raise AppException()
+            raise AppException(f'Database unusable {dbfile}.')
         else:
             logger.info(f'Database {dbfile} has {n} students.')
 
@@ -99,11 +98,12 @@ class App(object):
                 self.allow_student(student[0])
 
     # ------------------------------------------------------------------------
-    def exit(self):
-        if len(self.online) > 1:
-            online_students = ', '.join(self.online)
-            logger.warning(f'Students still online: {online_students}')
-        logger.critical('----------- !!! Server terminated !!! -----------')
+    # FIXME unused???
+    # def exit(self):
+    #     if len(self.online) > 1:
+    #         online_students = ', '.join(self.online)
+    #         logger.warning(f'Students still online: {online_students}')
+    #     logger.critical('----------- !!! Server terminated !!! -----------')
 
     # ------------------------------------------------------------------------
     async def login(self, uid, try_pw):
@@ -165,7 +165,7 @@ class App(object):
         logger.info(f'Student {uid}: {len(ans)} answers submitted.')
 
         grade = await t.correct()
-        logger.info(f'Student {uid}: grade = {grade} points.')
+        logger.info(f'Student {uid}: grade = {grade:5.3} points.')
 
         # --- save test in JSON format
         fields = (uid, t['ref'], str(t['finish_time']))
diff --git a/perguntations/questions.py b/perguntations/questions.py
index 8ac5bec..cb15512 100644
--- a/perguntations/questions.py
+++ b/perguntations/questions.py
@@ -1,5 +1,6 @@
 
 # python standard library
+import asyncio
 import random
 import re
 from os import path
@@ -21,10 +22,10 @@ class QuestionException(Exception):
     pass
 
 
-# ===========================================================================
+# ============================================================================
 # Questions derived from Question are already instantiated and ready to be
 # presented to students.
-# ===========================================================================
+# ============================================================================
 class Question(dict):
     '''
     Classes derived from this base class are meant to instantiate questions
@@ -41,7 +42,7 @@ class Question(dict):
             'comments': '',
             'solution': '',
             'files': {},
-            'max_tries': 3,
+            # 'max_tries': 3,
             }))
 
     def correct(self) -> None:
@@ -57,7 +58,7 @@ class Question(dict):
             self.setdefault(k, v)
 
 
-# ==========================================================================
+# ============================================================================
 class QuestionRadio(Question):
     '''An instance of QuestionRadio will always have the keys:
         type (str)
@@ -92,7 +93,7 @@ class QuestionRadio(Question):
                                for x in range(n)]
 
         if len(self['correct']) != n:
-            msg = (f'Options and correct mismatch in '
+            msg = ('Number of options and correct differ in '
                    f'"{self["ref"]}", file "{self["filename"]}".')
             logger.error(msg)
             raise QuestionException(msg)
@@ -138,7 +139,7 @@ class QuestionRadio(Question):
             self['grade'] = x
 
 
-# ===========================================================================
+# ============================================================================
 class QuestionCheckbox(Question):
     '''An instance of QuestionCheckbox will always have the keys:
         type (str)
@@ -218,7 +219,7 @@ class QuestionCheckbox(Question):
                 self['grade'] = x / sum_abs
 
 
-# ===========================================================================
+# ============================================================================
 class QuestionText(Question):
     '''An instance of QuestionText will always have the keys:
         type (str)
@@ -251,13 +252,16 @@ class QuestionText(Question):
             self['grade'] = 1.0 if self['answer'] in self['correct'] else 0.0
 
 
-# ===========================================================================
+# ============================================================================
 class QuestionTextRegex(Question):
     '''An instance of QuestionTextRegex will always have the keys:
         type (str)
         text (str)
-        correct (str with regex)
+        correct (str or list[str])
         answer (None or an actual answer)
+
+        The correct strings are python standard regular expressions.
+        Grade is 1.0 when the answer matches any of the regex in the list.
     '''
 
     # ------------------------------------------------------------------------
@@ -266,22 +270,32 @@ class QuestionTextRegex(Question):
 
         self.set_defaults(QDict({
             'text': '',
-            'correct': '$.^',   # will always return false
+            'correct': ['$.^'],   # will always return false
             }))
 
+        # make sure its always a list of regular expressions
+        if not isinstance(self['correct'], list):
+            self['correct'] = [self['correct']]
+
+        # make sure all elements of the list are strings
+        self['correct'] = [str(a) for a in self['correct']]
+
     # ------------------------------------------------------------------------
     def correct(self) -> None:
         super().correct()
         if self['answer'] is not None:
-            try:
-                ok = re.match(self['correct'], self['answer'])
-            except TypeError:
-                logger.error(f'While matching regex {self["correct"]} with '
-                             f'answer {self["answer"]}.')
-            self['grade'] = 1.0 if ok else 0.0
+            self['grade'] = 0.0
+            for r in self['correct']:
+                try:
+                    if re.match(r, self['answer']):
+                        self['grade'] = 1.0
+                        return
+                except TypeError:
+                    logger.error(f'While matching regex {self["correct"]} with'
+                                 f' answer "{self["answer"]}".')
 
 
-# ===========================================================================
+# ============================================================================
 class QuestionNumericInterval(Question):
     '''An instance of QuestionTextNumeric will always have the keys:
         type (str)
@@ -316,7 +330,7 @@ class QuestionNumericInterval(Question):
                 self['grade'] = 1.0 if lower <= answer <= upper else 0.0
 
 
-# ===========================================================================
+# ============================================================================
 class QuestionTextArea(Question):
     '''An instance of QuestionTextArea will always have the keys:
         type (str)
@@ -397,7 +411,7 @@ class QuestionTextArea(Question):
                     logger.error(f'Invalid grade in "{self["correct"]}".')
 
 
-# ===========================================================================
+# ============================================================================
 class QuestionInformation(Question):
     # ------------------------------------------------------------------------
     def __init__(self, q: QDict) -> None:
@@ -412,30 +426,38 @@ class QuestionInformation(Question):
         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.
+# To generate an instance of a question we use the method generate().
+# It returns a question instance of the correct class.
+# There is also an asynchronous version called gen_async(). This version is
+# synchronous for all question types (radio, checkbox, etc) except for
+# generator types which run asynchronously.
 #
 # Example:
 #
-#   # generate a question instance from a dictionary
-#   qdict = {
+#   # make a factory for a question
+#   qfactory = QFactory({
 #       'type': 'radio',
 #       'text': 'Choose one',
 #       'options': ['a', 'b']
-#       }
-#   qfactory = QFactory(qdict)
+#       })
+#
+#   # generate synchronously
 #   question = qfactory.generate()
 #
+#   # generate asynchronously
+#   question = await qfactory.gen_async()
+#
 #   # 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`.
@@ -456,43 +478,12 @@ class QFactory(object):
     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"]}"...')
+    # ------------------------------------------------------------------------
+    # generates a question instance of QuestionRadio, QuestionCheckbox, ...,
+    # which is a descendent of base class Question.
+    # ------------------------------------------------------------------------
+    async def gen_async(self) -> Question:
+        logger.debug(f'generating {self.question["ref"]}...')
         # Shallow copy so that script generated questions will not replace
         # the original generators
         q = self.question.copy()
@@ -520,5 +511,8 @@ class QFactory(object):
             logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
             raise
         else:
-            logger.debug(f'[generate_async] Done instance of {q["ref"]}')
             return qinstance
+
+    # ------------------------------------------------------------------------
+    def generate(self) -> Question:
+        return asyncio.get_event_loop().run_until_complete(self.gen_async())
diff --git a/perguntations/serve.py b/perguntations/serve.py
index 0d232b5..7589c9b 100644
--- a/perguntations/serve.py
+++ b/perguntations/serve.py
@@ -490,7 +490,7 @@ def main():
 
     try:
         testapp = App(config)
-    except AppException:
+    except Exception:
         logging.critical('Failed to start application.')
         sys.exit(-1)
 
diff --git a/perguntations/static/js/admin.js b/perguntations/static/js/admin.js
index b610456..7d63603 100644
--- a/perguntations/static/js/admin.js
+++ b/perguntations/static/js/admin.js
@@ -70,10 +70,10 @@ $(document).ready(function() {
         else
             barcolor = 'bg-success';
 
-        return '
' + grade + '
'; + + (5*grade) + '%;">' + Math.round(10*grade)/10 + '
'; } // ---------------------------------------------------------------------- diff --git a/perguntations/templates/grade.html b/perguntations/templates/grade.html index 82fa3d1..48d6caa 100644 --- a/perguntations/templates/grade.html +++ b/perguntations/templates/grade.html @@ -39,69 +39,70 @@
-
-

Resultado

- - {% if t['state'] == 'FINISHED' %} -

{{t['grade']}} valores na escala de 0 a 20.

- {% if t['grade'] >= 15 %} - - - {% end %} - {% elif t['state'] == 'QUIT' %} -

Foi registada a sua desistência da prova.

- +
+ {% if t['state'] == 'FINISHED' %} +

Resultado: + {{ f'{round(t["grade"], 1)}' }} + valores na escala de {{t['scale_min']}} a {{t['scale_max']}}. +

+

O seu teste foi registado.
+ Pode fechar o browser e desligar o computador.

+ {% if t['grade'] - t['scale_min'] >= 0.75*(t['scale_max'] - t['scale_min']) %} + {% end %} + {% elif t['state'] == 'QUIT' %} +

Foi registada a sua desistência da prova.

+ {% end %} + +
-

Pode fechar o browser e desligar o computador.

-
+
+
+ Histórico de resultados +
+ + + + + + + + + + + {% for g in allgrades %} + + + + + + + {% end %} + +
ProvaDataHoraNota
{{g[0]}} {{g[2][:10]}} {{g[2][11:19]}} +
+
-
-
- Histórico de resultados -
- - - - - - - - - - - - {% for g in allgrades %} - - - - - - - {% end %} - -
ProvaDataHoraNota (0-20)
{{g[0]}} {{g[2][:10]}} {{g[2][11:19]}} -
-
+ {{ str(round(g[1], 1)) }} - {{ '{:.1f}'.format(g[1]) }} -
-
-
- -
+
+
+
+
diff --git a/perguntations/templates/question.html b/perguntations/templates/question.html index 41b7e9a..c666ccf 100644 --- a/perguntations/templates/question.html +++ b/perguntations/templates/question.html @@ -18,7 +18,13 @@

- (Cotação: {{ round(q['points'], 2) }} pontos) + {% if q['type'] == 'radio' %} + (Cotação: {{ -round(q['points']/(len(q['options'])-1), 2) }} a {{ round(q['points'], 2) }} pontos) + {% elif q['type'] == 'checkbox' %} + (Cotação: {{ -round(q['points'], 2) }} a {{ round(q['points'], 2) }} pontos) + {% else %} + (Cotação: 0 a {{ round(q['points'], 2) }} pontos) + {% end %}

diff --git a/perguntations/templates/review-question.html b/perguntations/templates/review-question.html index 3c66125..13bcb05 100644 --- a/perguntations/templates/review-question.html +++ b/perguntations/templates/review-question.html @@ -21,7 +21,13 @@

- (Cotação: {{ round(q['points'], 2) }}) + {% if q['type'] == 'radio' %} + (Cotação: {{ -round(q['points']/(len(q['options'])-1), 2) }} a {{ round(q['points'], 2) }} pontos) + {% elif q['type'] == 'checkbox' %} + (Cotação: {{ -round(q['points'], 2) }} a {{ round(q['points'], 2) }} pontos) + {% else %} + (Cotação: 0 a {{ round(q['points'], 2) }} pontos) + {% end %}

diff --git a/perguntations/templates/review.html b/perguntations/templates/review.html index 44f4f02..1e147fa 100644 --- a/perguntations/templates/review.html +++ b/perguntations/templates/review.html @@ -95,7 +95,7 @@
- {{ t['grade'] }} valores + {{ round(t['grade'], 1) }} valores {% if t['state'] == 'QUIT' %} (DESISTÊNCIA) {% end %} diff --git a/perguntations/templates/test.html b/perguntations/templates/test.html index f00c44e..da24332 100644 --- a/perguntations/templates/test.html +++ b/perguntations/templates/test.html @@ -17,9 +17,7 @@ } }; - + diff --git a/perguntations/test.py b/perguntations/test.py index 12b9380..081c963 100644 --- a/perguntations/test.py +++ b/perguntations/test.py @@ -31,60 +31,59 @@ class TestFactory(dict): # Base questions are added to a pool of questions factories. # ------------------------------------------------------------------------ def __init__(self, conf): - # --- set test configutation and defaults + # --- set test defaults and then use given configuration super().__init__({ # defaults 'title': '', 'show_points': False, 'scale_points': True, 'scale_max': 20.0, - 'duration': 0, # infinite + 'scale_min': 0.0, + 'duration': 0, # 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 + self.sanity_checks() + logger.info('Sanity checks PASSED.') + + # --- find refs of all questions used in the test + qrefs = {r for qq in self['questions'] for r in qq['ref']} + logger.info(f'Declared {len(qrefs)} questions (each test uses {len(self["questions"])}).') # --- for review, we are done. no factories needed if self['review']: logger.info('Review mode. No questions loaded. No factories.') return - # --- 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 + n = 1 for file in self["files"]: fullpath = path.normpath(path.join(self["questions_dir"], file)) (dirname, filename) = path.split(fullpath) - questions = load_yaml(fullpath, default=[]) + logger.info(f'Loading "{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() + raise TestFactoryException(f'Question {i} in {file} is not a dictionary!') - # if ref is missing, then set to '/path/file.yaml:3' - q.setdefault('ref', f'{file}:{i}') + # check if ref is missing, then set to '/path/file.yaml:3' + if 'ref' not in q: + q['ref'] = f'{file}:{i:04}' + logger.warning(f'Missing "ref" set to "{q["ref"]}"') # 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() + otherfile = path.join(other.question['path'], other.question['filename']) + raise TestFactoryException(f'Duplicate reference "{q["ref"]}" in files ' + f'"{otherfile}" and "{fullpath}".') # make factory only for the questions used in the test if q['ref'] in qrefs: @@ -100,77 +99,73 @@ class TestFactory(dict): # 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 + except Exception as e: + raise TestFactoryException(f'Failed to generate "{q["ref"]}"') 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. Ready for "{self["ref"]}".') + qmissing = qrefs.difference(set(self.question_factory.keys())) + if qmissing: + raise TestFactoryException(f'Could not find questions {qmissing}.') + # ------------------------------------------------------------------------ # Checks for valid keys and sets default values. # Also checks if some files and directories exist # ------------------------------------------------------------------------ def sanity_checks(self): - # --- database + # --- ref + if 'ref' not in self: + raise TestFactoryException('Missing "ref" in configuration!') + + # --- check database if 'database' not in self: - logger.critical('Missing "database" in configuration.') - raise TestFactoryException() + raise TestFactoryException('Missing "database" in configuration!') elif not path.isfile(path.expanduser(self['database'])): - logger.critical(f'Can\'t find database {self["database"]}.') - raise TestFactoryException() + raise TestFactoryException(f'Database {self["database"]} not found!') - # --- answers_dir + # --- check answers_dir if 'answers_dir' not in self: - logger.critical('Missing "answers_dir".') - raise TestFactoryException() + raise TestFactoryException('Missing "answers_dir" in configuration!') + # --- check if answers_dir is a writable directory testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') - try: # check if answers_dir is a writable directory + try: with open(testfile, 'w') as f: f.write('You can safely remove this file.') except OSError: - logger.critical(f'Can\'t write answers to "{self["answers_dir"]}"') - raise TestFactoryException() + raise TestFactoryException(f'Cannot write answers to directory "{self["answers_dir"]}"!') - # --- ref - if 'ref' not in self: - logger.warning('Missing "ref". Will use filename.') - self['ref'] = self['filename'] + # --- check title + if not self['title']: + logger.warning('Undefined title!') + + if self['scale_points']: + logger.info(f'Grades are scaled to the interval [{self["scale_min"]}, {self["scale_max"]}]') + else: + logger.info('Grades are just the sum of points defined for the questions, not being scaled.') # --- questions_dir if 'questions_dir' not in self: logger.warning(f'Missing "questions_dir". ' - f'Using {path.abspath(path.curdir)}') + f'Using "{path.abspath(path.curdir)}"') self['questions_dir'] = path.curdir elif not path.isdir(path.expanduser(self['questions_dir'])): - logger.critical(f'Can\'t find questions directory ' - f'"{self["questions_dir"]}"') - raise TestFactoryException() + raise TestFactoryException(f'Can\'t find questions directory ' + f'"{self["questions_dir"]}"') # --- files if 'files' not in self: - logger.warning('Missing "files" key. Loading all YAML files from ' - '"questions_dir"... DANGEROUS!!!') - try: - self['files'] = fnmatch.filter(listdir(self['questions_dir']), - '*.yaml') - except OSError: - logger.critical('Couldn\'t get list of YAML question files.') - raise TestFactoryException() + raise TestFactoryException('Missing "files" in configuration with the list of question files to import!') + # FIXME allow no files and define the questions directly in the test + if isinstance(self['files'], str): self['files'] = [self['files']] # --- questions if 'questions' not in self: - logger.critical(f'Missing "questions" in {self["filename"]}.') - raise TestFactoryException() + raise TestFactoryException(f'Missing "questions" in Configuration!') for i, q in enumerate(self['questions']): # normalize question to a dict and ref to a list of references @@ -178,6 +173,8 @@ class TestFactory(dict): q = {'ref': [q]} # becomes - ref: [some_ref] elif isinstance(q, dict) and isinstance(q['ref'], str): q['ref'] = [q['ref']] + elif isinstance(q, list): + q = {'ref': [str(a) for a in q]} self['questions'][i] = q @@ -186,9 +183,8 @@ class TestFactory(dict): # returns instance of Test() for that particular student # ------------------------------------------------------------------------ async def generate(self, student): + # make list of questions test = [] - # total_points = 0.0 - n = 1 # track question number nerr = 0 # count errors generating questions @@ -198,7 +194,7 @@ class TestFactory(dict): # generate instance of question try: - q = await self.question_factory[qref].generate_async() + q = await self.question_factory[qref].gen_async() except Exception: logger.error(f'Can\'t generate question "{qref}". Skipping.') nerr += 1 @@ -217,12 +213,12 @@ class TestFactory(dict): # normalize question points to scale if self['scale_points']: total_points = sum(q['points'] for q in test) - for q in test: - 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 total_points == 0: + logger.warning('Can\'t scale, total points in the test is 0!') + else: + scale = (self['scale_max'] - self['scale_min']) / total_points + for q in test: + q['points'] *= scale if nerr > 0: logger.error(f'{nerr} errors found!') @@ -234,6 +230,8 @@ class TestFactory(dict): 'questions': test, # list of Question instances 'answers_dir': self['answers_dir'], 'duration': self['duration'], + 'scale_min': self['scale_min'], + 'scale_max': self['scale_max'], 'show_points': self['show_points'], 'show_ref': self['show_ref'], 'debug': self['debug'], # required by template test.html @@ -267,8 +265,8 @@ class Test(dict): q['answer'] = None # ------------------------------------------------------------------------ - # Given a dictionary ans={'ref': 'some answer'} updates the - # answers of the test. Only affects questions referred. + # Given a dictionary ans={'ref': 'some answer'} updates the answers of the + # test. Only affects the questions referred in the dictionary. def update_answers(self, ans): for ref, answer in ans.items(): self['questions'][ref]['answer'] = answer @@ -284,7 +282,8 @@ class Test(dict): grade += q['grade'] * q['points'] logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%') - self['grade'] = max(0, round(grade, 1)) # truncate negative grades + # truncate to avoid negative grade and adjust scale + self['grade'] = max(0.0, grade) + self['scale_min'] return self['grade'] # ------------------------------------------------------------------------ diff --git a/perguntations/tools.py b/perguntations/tools.py index 17bc8c9..b1a726f 100644 --- a/perguntations/tools.py +++ b/perguntations/tools.py @@ -21,26 +21,22 @@ def load_yaml(filename: str, default: Any = None) -> Any: filename = path.expanduser(filename) try: f = open(filename, 'r', encoding='utf-8') - except FileNotFoundError: - logger.error(f'Cannot open "{filename}": not found') - except PermissionError: - logger.error(f'Cannot open "{filename}": no permission') - except OSError: - logger.error(f'Cannot open file "{filename}"') - else: - with f: - try: - default = yaml.safe_load(f) - except yaml.YAMLError as e: - if hasattr(e, 'problem_mark'): - mark = e.problem_mark - logger.error(f'File "{filename}" near line {mark.line}, ' - f'column {mark.column+1}') - else: - logger.error(f'File "{filename}"') - finally: - return default + except Exception as e: + logger.error(e) + if default is not None: + return default + else: + raise + with f: + try: + return yaml.safe_load(f) + except yaml.YAMLError as e: + logger.error(str(e).replace('\n', ' ')) + if default is not None: + return default + else: + raise # --------------------------------------------------------------------------- # Runs a script and returns its stdout parsed as yaml, or None on error. -- libgit2 0.21.2