Commit 1200ef9cd00ee5f329dcf18f0d140ad7a5aff94d
1 parent
da470fc6
Exists in
master
and in
1 other branch
fix README.md instructions and demo.
code refactoring. pre-generate tests
Showing
15 changed files
with
227 additions
and
160 deletions
Show diff stats
README.md
| @@ -39,7 +39,7 @@ This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in | @@ -39,7 +39,7 @@ This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in | ||
| 39 | Download and install: | 39 | Download and install: |
| 40 | 40 | ||
| 41 | ```sh | 41 | ```sh |
| 42 | -git clone https://git.xdi.uevora.pt/perguntations.git | 42 | +git clone https://git.xdi.uevora.pt/mjsb/perguntations.git |
| 43 | cd perguntations | 43 | cd perguntations |
| 44 | npm install | 44 | npm install |
| 45 | pip3 install . | 45 | pip3 install . |
| @@ -225,7 +225,7 @@ Python packages can be upgraded independently of the rest using pip: | @@ -225,7 +225,7 @@ Python packages can be upgraded independently of the rest using pip: | ||
| 225 | 225 | ||
| 226 | ```sh | 226 | ```sh |
| 227 | pip list --outdated # lists upgradable packages | 227 | pip list --outdated # lists upgradable packages |
| 228 | -pip install -U something # upgrade something | 228 | +pip install -U something # upgrade something |
| 229 | ``` | 229 | ``` |
| 230 | 230 | ||
| 231 | To upgrade perguntations and javascript libraries do: | 231 | To upgrade perguntations and javascript libraries do: |
demo/demo.yaml
| @@ -22,7 +22,7 @@ title: Teste de demonstração (tutorial) | @@ -22,7 +22,7 @@ title: Teste de demonstração (tutorial) | ||
| 22 | 22 | ||
| 23 | # Duration in minutes. | 23 | # Duration in minutes. |
| 24 | # (0 or undefined means infinite time) | 24 | # (0 or undefined means infinite time) |
| 25 | -duration: 5 | 25 | +duration: 20 |
| 26 | 26 | ||
| 27 | # Automatic test submission after the given 'duration' timeout | 27 | # Automatic test submission after the given 'duration' timeout |
| 28 | # (default: false) | 28 | # (default: false) |
| @@ -37,11 +37,6 @@ show_points: true | @@ -37,11 +37,6 @@ show_points: true | ||
| 37 | # (default: no scaling, just use question points) | 37 | # (default: no scaling, just use question points) |
| 38 | scale: [0, 5] | 38 | scale: [0, 5] |
| 39 | 39 | ||
| 40 | -# DEPRECATED: old version, to be removed | ||
| 41 | -# scale_max: 20 | ||
| 42 | -# scale_min: 0 | ||
| 43 | -# scale_points: true | ||
| 44 | - | ||
| 45 | # ---------------------------------------------------------------------------- | 40 | # ---------------------------------------------------------------------------- |
| 46 | # Base path applied to the questions files and all the scripts | 41 | # Base path applied to the questions files and all the scripts |
| 47 | # including question generators and correctors. | 42 | # including question generators and correctors. |
demo/questions/generators/generate-question.py
| @@ -8,6 +8,7 @@ Arguments are read from stdin. | @@ -8,6 +8,7 @@ Arguments are read from stdin. | ||
| 8 | from random import randint | 8 | from random import randint |
| 9 | import sys | 9 | import sys |
| 10 | 10 | ||
| 11 | +# read two arguments from the field `args` specified in the question yaml file | ||
| 11 | a, b = (int(n) for n in sys.argv[1:]) | 12 | a, b = (int(n) for n in sys.argv[1:]) |
| 12 | 13 | ||
| 13 | x = randint(a, b) | 14 | x = randint(a, b) |
| @@ -18,10 +19,13 @@ print(f"""--- | @@ -18,10 +19,13 @@ print(f"""--- | ||
| 18 | type: text | 19 | type: text |
| 19 | title: Geradores de perguntas | 20 | title: Geradores de perguntas |
| 20 | text: | | 21 | text: | |
| 21 | - Existe a possibilidade da pergunta ser gerada por um programa externo. O | ||
| 22 | - programa deve escrever no `stdout` uma pergunta em formato `yaml` tal como os | ||
| 23 | - exemplos anteriores. Pode também receber argumentos para parametrizar a | ||
| 24 | - pergunta. Aqui está um exemplo de uma pergunta gerada por um script python: | 22 | + |
| 23 | + As perguntas podem ser estáticas (como as que vimos até aqui), ou serem | ||
| 24 | + geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o | ||
| 25 | + programa deve escrever texto no `stdout` em formato `yaml` tal como os | ||
| 26 | + exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode | ||
| 27 | + também receber argumentos de linha de comando para parametrizar a pergunta. | ||
| 28 | + Aqui está um exemplo de uma pergunta gerada por um script python: | ||
| 25 | 29 | ||
| 26 | ```python | 30 | ```python |
| 27 | #!/usr/bin/env python3 | 31 | #!/usr/bin/env python3 |
| @@ -46,7 +50,7 @@ text: | | @@ -46,7 +50,7 @@ text: | | ||
| 46 | ``` | 50 | ``` |
| 47 | 51 | ||
| 48 | Este script deve ter permissões para poder ser executado no terminal. | 52 | Este script deve ter permissões para poder ser executado no terminal. |
| 49 | - Podemos testar o programa no terminal `./gen-somar.py 1 50` e verificar que | 53 | + Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que |
| 50 | o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar | 54 | o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar |
| 51 | que este script deve ser usado para gerar uma pergunta. | 55 | que este script deve ser usado para gerar uma pergunta. |
| 52 | 56 | ||
| @@ -60,7 +64,8 @@ text: | | @@ -60,7 +64,8 @@ text: | | ||
| 60 | args: [1, 100] | 64 | args: [1, 100] |
| 61 | ``` | 65 | ``` |
| 62 | 66 | ||
| 63 | - O programa pode receber uma lista de argumentos declarados em `args`. | 67 | + O programa pode receber uma lista de argumentos de linha de comando |
| 68 | + declarados em `args`. | ||
| 64 | 69 | ||
| 65 | --- | 70 | --- |
| 66 | 71 |
demo/questions/questions-tutorial.yaml
| @@ -24,9 +24,8 @@ | @@ -24,9 +24,8 @@ | ||
| 24 | duration: 60 # duração da prova em minutos (default: inf) | 24 | duration: 60 # duração da prova em minutos (default: inf) |
| 25 | autosubmit: true # submissão automática (default: false) | 25 | autosubmit: true # submissão automática (default: false) |
| 26 | show_points: true # mostra cotação das perguntas (default: true) | 26 | show_points: true # mostra cotação das perguntas (default: true) |
| 27 | - scale_points: true # recalcula cotações para [scale_min, scale_max] | ||
| 28 | - scale_max: 20 # limite superior da escala (default: 20) | ||
| 29 | - scale_min: 0 # limite inferior da escala (default: 0) | 27 | + scale: [0, 20] # limites inferior e superior da escala (default: [0,20]) |
| 28 | + scale_points: true # normaliza cotações para a escala definida | ||
| 30 | debug: false # mostra informação de debug no browser | 29 | debug: false # mostra informação de debug no browser |
| 31 | 30 | ||
| 32 | # -------------------------------------------------------------------------- | 31 | # -------------------------------------------------------------------------- |
| @@ -48,9 +47,9 @@ | @@ -48,9 +47,9 @@ | ||
| 48 | points: 3.5 | 47 | points: 3.5 |
| 49 | 48 | ||
| 50 | - ref: pergunta2 | 49 | - ref: pergunta2 |
| 51 | - point: 2.0 | 50 | + points: 2.0 |
| 52 | 51 | ||
| 53 | - # a cotação é 1.0 por defeito, se omitida | 52 | + # a cotação é 1.0 por defeito |
| 54 | - ref: pergunta3 | 53 | - ref: pergunta3 |
| 55 | 54 | ||
| 56 | # uma string (não dict), é interpretada como referência | 55 | # uma string (não dict), é interpretada como referência |
| @@ -153,17 +152,19 @@ | @@ -153,17 +152,19 @@ | ||
| 153 | entre 0 e 1, sendo atribuída a respectiva cotação, mas só o valor 1 | 152 | entre 0 e 1, sendo atribuída a respectiva cotação, mas só o valor 1 |
| 154 | representa uma opção certa. | 153 | representa uma opção certa. |
| 155 | 154 | ||
| 156 | - Por defeito, as opções são apresentadas por ordem aleatória. | ||
| 157 | - Para manter a ordem definida acrescenta-se: | 155 | + Por defeito, as opções são apresentadas por ordem aleatória, mas é possível |
| 156 | + usar a ordem predefinida. Por exemplo, para manter a ordem e indicar que a | ||
| 157 | + resposta correcta é a do meio define-se: | ||
| 158 | 158 | ||
| 159 | ```yaml | 159 | ```yaml |
| 160 | + correct: [0, 0, 1, 0, 0] | ||
| 160 | shuffle: false | 161 | shuffle: false |
| 161 | ``` | 162 | ``` |
| 162 | 163 | ||
| 163 | - Por defeito, as respostas erradas descontam, tendo uma cotação de | ||
| 164 | - $-1/(n-1)$ do valor da pergunta, onde $n$ é o número de opções apresentadas | ||
| 165 | - ao aluno (a ideia é o valor esperado ser zero quando as respostas são | ||
| 166 | - aleatórias e uniformemente distribuídas). Para não descontar acrescenta-se: | 164 | + As respostas erradas descontam, tendo uma cotação de $-1/(n-1)$ do valor da |
| 165 | + pergunta, onde $n$ é o número de opções apresentadas ao aluno (a ideia é o | ||
| 166 | + valor esperado ser zero quando as respostas são aleatórias e uniformemente | ||
| 167 | + distribuídas). Para não descontar acrescenta-se: | ||
| 167 | 168 | ||
| 168 | ```yaml | 169 | ```yaml |
| 169 | discount: false | 170 | discount: false |
| @@ -269,7 +270,7 @@ | @@ -269,7 +270,7 @@ | ||
| 269 | Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`. | 270 | Neste caso, as respostas aceites são `azul`, `Azul` ou `AZUL`. |
| 270 | 271 | ||
| 271 | Em alguns casos pode ser conveniente transformar a resposta antes de a | 272 | Em alguns casos pode ser conveniente transformar a resposta antes de a |
| 272 | - comparar, por exemplo para remover espaços ou converter para maiúsculas. | 273 | + comparar, por exemplo para remover espaços ou converter para minúsculas. |
| 273 | A opção `transform` permite dar uma sequência de transformações a aplicar à | 274 | A opção `transform` permite dar uma sequência de transformações a aplicar à |
| 274 | resposta do aluno, por exemplo: | 275 | resposta do aluno, por exemplo: |
| 275 | 276 | ||
| @@ -278,10 +279,10 @@ | @@ -278,10 +279,10 @@ | ||
| 278 | correct: ['azul'] | 279 | correct: ['azul'] |
| 279 | ``` | 280 | ``` |
| 280 | 281 | ||
| 281 | - Neste momento estão disponíveis as seguintes transformações: | 282 | + Estão disponíveis as seguintes transformações: |
| 282 | 283 | ||
| 283 | - * `trim` remove os espaços do início e fim da resposta, os do meio mantêm-se | ||
| 284 | - inalterados. | 284 | + * `trim` remove os espaços do início e fim da resposta, os espaços do meio |
| 285 | + mantêm-se inalterados. | ||
| 285 | * `remove_space` remove todos os espaços (início, meio e fim). | 286 | * `remove_space` remove todos os espaços (início, meio e fim). |
| 286 | * `normalize_space` remove espaços do início e fim (trim), e substitui | 287 | * `normalize_space` remove espaços do início e fim (trim), e substitui |
| 287 | múltiplos espaços por um único espaço (no meio). | 288 | múltiplos espaços por um único espaço (no meio). |
| @@ -375,10 +376,11 @@ | @@ -375,10 +376,11 @@ | ||
| 375 | são as mais flexíveis. | 376 | são as mais flexíveis. |
| 376 | 377 | ||
| 377 | A resposta é enviada para um programa externo para ser avaliada. | 378 | A resposta é enviada para um programa externo para ser avaliada. |
| 378 | - O programa externo é um programa escrito numa linguagem qualquer, desde que | ||
| 379 | - seja executável pelo sistema operativo (pode ser um script ou binário). | ||
| 380 | - Este programa recebe a resposta submetida pelo aluno via `stdin` e devolve | ||
| 381 | - a classificação via `stdout`. | 379 | + O programa externo é um programa que tem de ser executável pelo pelo |
| 380 | + sistema operativo (pode ser um binário ou script desde que o respectivo | ||
| 381 | + interpretador instalado). | ||
| 382 | + Este programa externo recebe a resposta submetida pelo aluno via `stdin` e | ||
| 383 | + devolve a classificação via `stdout`. | ||
| 382 | Exemplo: | 384 | Exemplo: |
| 383 | 385 | ||
| 384 | ```yaml | 386 | ```yaml |
| @@ -566,7 +568,7 @@ | @@ -566,7 +568,7 @@ | ||
| 566 | duas possibilidads: | 568 | duas possibilidads: |
| 567 | 569 | ||
| 568 | - Imagens inline: não têm título definido e podem ser incluídas no meio de | 570 | - Imagens inline: não têm título definido e podem ser incluídas no meio de |
| 569 | - uma linha de texto usando``. | 571 | + uma linha de texto usando ``. |
| 570 | - Imagens centradas com título: ``. | 572 | - Imagens centradas com título: ``. |
| 571 | O título é colocado por baixo da imagem. Pode ser uma string vazia. | 573 | O título é colocado por baixo da imagem. Pode ser uma string vazia. |
| 572 | 574 |
package.json
| @@ -2,13 +2,13 @@ | @@ -2,13 +2,13 @@ | ||
| 2 | "description": "Javascript libraries required to run the server", | 2 | "description": "Javascript libraries required to run the server", |
| 3 | "email": "mjsb@uevora.pt", | 3 | "email": "mjsb@uevora.pt", |
| 4 | "dependencies": { | 4 | "dependencies": { |
| 5 | - "@fortawesome/fontawesome-free": "^5.13.0", | ||
| 6 | - "bootstrap": "^4.4.1", | ||
| 7 | - "codemirror": "^5.53.2", | 5 | + "@fortawesome/fontawesome-free": "^5.15.1", |
| 6 | + "bootstrap": "^4.5.3", | ||
| 7 | + "codemirror": "^5.58.1", | ||
| 8 | "datatables": "^1.10", | 8 | "datatables": "^1.10", |
| 9 | "jquery": "^3.5.1", | 9 | "jquery": "^3.5.1", |
| 10 | - "mathjax": "^3.0.5", | 10 | + "mathjax": "^3.1.2", |
| 11 | "popper.js": "^1.16.1", | 11 | "popper.js": "^1.16.1", |
| 12 | - "underscore": "^1.10" | 12 | + "underscore": "^1.11.0" |
| 13 | } | 13 | } |
| 14 | } | 14 | } |
perguntations/__init__.py
| @@ -32,7 +32,7 @@ proof of submission and for review. | @@ -32,7 +32,7 @@ proof of submission and for review. | ||
| 32 | ''' | 32 | ''' |
| 33 | 33 | ||
| 34 | APP_NAME = 'perguntations' | 34 | APP_NAME = 'perguntations' |
| 35 | -APP_VERSION = '2020.05.dev6' | 35 | +APP_VERSION = '2020.11.dev1' |
| 36 | APP_DESCRIPTION = __doc__ | 36 | APP_DESCRIPTION = __doc__ |
| 37 | 37 | ||
| 38 | __author__ = 'Miguel Barão' | 38 | __author__ = 'Miguel Barão' |
perguntations/app.py
| @@ -88,24 +88,9 @@ class App(): | @@ -88,24 +88,9 @@ class App(): | ||
| 88 | self.allowed = set() # '0' is hardcoded to allowed elsewhere | 88 | self.allowed = set() # '0' is hardcoded to allowed elsewhere |
| 89 | self.unfocus = set() # set of students that have no browser focus | 89 | self.unfocus = set() # set of students that have no browser focus |
| 90 | self.area = dict() # {uid: percent_area} | 90 | self.area = dict() # {uid: percent_area} |
| 91 | + self.pregenerated_tests = [] # list of tests to give to students | ||
| 91 | 92 | ||
| 92 | - logger.info('Loading test configuration "%s".', conf["testfile"]) | ||
| 93 | - try: | ||
| 94 | - testconf = load_yaml(conf['testfile']) | ||
| 95 | - except Exception as exc: | ||
| 96 | - logger.critical('Error loading test configuration YAML.') | ||
| 97 | - raise AppException(exc) | ||
| 98 | - | ||
| 99 | - testconf.update(conf) # command line options override configuration | ||
| 100 | - | ||
| 101 | - # start test factory | ||
| 102 | - try: | ||
| 103 | - self.testfactory = TestFactory(testconf) | ||
| 104 | - except TestFactoryException as exc: | ||
| 105 | - logger.critical(exc) | ||
| 106 | - raise AppException('Failed to create test factory!') | ||
| 107 | - else: | ||
| 108 | - logger.info('No errors found. Test factory ready.') | 93 | + self._make_test_factory(conf) |
| 109 | 94 | ||
| 110 | # connect to database and check registered students | 95 | # connect to database and check registered students |
| 111 | dbfile = self.testfactory['database'] | 96 | dbfile = self.testfactory['database'] |
| @@ -115,28 +100,22 @@ class App(): | @@ -115,28 +100,22 @@ class App(): | ||
| 115 | try: | 100 | try: |
| 116 | with self.db_session() as sess: | 101 | with self.db_session() as sess: |
| 117 | num = sess.query(Student).filter(Student.id != '0').count() | 102 | num = sess.query(Student).filter(Student.id != '0').count() |
| 118 | - except Exception: | ||
| 119 | - raise AppException(f'Database unusable {dbfile}.') | ||
| 120 | - else: | ||
| 121 | - logger.info('Database "%s" has %s students.', dbfile, num) | 103 | + except Exception as exc: |
| 104 | + raise AppException(f'Database unusable {dbfile}.') from exc | ||
| 105 | + logger.info('Database "%s" has %s students.', dbfile, num) | ||
| 106 | + | ||
| 107 | + # pre-generate tests | ||
| 108 | + logger.info('Generating tests for %d students:', num) | ||
| 109 | + self._pregenerate_tests(num) | ||
| 110 | + logger.info('Tests are ready.') | ||
| 122 | 111 | ||
| 123 | # command line option --allow-all | 112 | # command line option --allow-all |
| 124 | if conf['allow_all']: | 113 | if conf['allow_all']: |
| 125 | - logger.info('Allowing all students:') | ||
| 126 | - for student in self.get_all_students(): | ||
| 127 | - self.allow_student(student[0]) | 114 | + self.allow_all_students() |
| 128 | else: | 115 | else: |
| 129 | logger.info('Students not yet allowed to login.') | 116 | logger.info('Students not yet allowed to login.') |
| 130 | 117 | ||
| 131 | # ------------------------------------------------------------------------ | 118 | # ------------------------------------------------------------------------ |
| 132 | - # FIXME unused??? | ||
| 133 | - # def exit(self): | ||
| 134 | - # if len(self.online) > 1: | ||
| 135 | - # online_students = ', '.join(self.online) | ||
| 136 | - # logger.warning(f'Students still online: {online_students}') | ||
| 137 | - # logger.critical('----------- !!! Server terminated !!! -----------') | ||
| 138 | - | ||
| 139 | - # ------------------------------------------------------------------------ | ||
| 140 | async def login(self, uid, try_pw): | 119 | async def login(self, uid, try_pw): |
| 141 | '''login authentication''' | 120 | '''login authentication''' |
| 142 | if uid not in self.allowed and uid != '0': # not allowed | 121 | if uid not in self.allowed and uid != '0': # not allowed |
| @@ -175,18 +154,60 @@ class App(): | @@ -175,18 +154,60 @@ class App(): | ||
| 175 | logger.info('"%s" logged out.', uid) | 154 | logger.info('"%s" logged out.', uid) |
| 176 | 155 | ||
| 177 | # ------------------------------------------------------------------------ | 156 | # ------------------------------------------------------------------------ |
| 157 | + def _make_test_factory(self, conf): | ||
| 158 | + ''' | ||
| 159 | + Setup a factory for the test | ||
| 160 | + ''' | ||
| 161 | + | ||
| 162 | + # load configuration from yaml file | ||
| 163 | + logger.info('Loading test configuration "%s".', conf["testfile"]) | ||
| 164 | + try: | ||
| 165 | + testconf = load_yaml(conf['testfile']) | ||
| 166 | + except Exception as exc: | ||
| 167 | + msg = 'Error loading test configuration YAML.' | ||
| 168 | + logger.critical(msg) | ||
| 169 | + raise AppException(msg) from exc | ||
| 170 | + | ||
| 171 | + # command line options override configuration | ||
| 172 | + testconf.update(conf) | ||
| 173 | + | ||
| 174 | + # start test factory | ||
| 175 | + logger.info('Making test factory...') | ||
| 176 | + try: | ||
| 177 | + self.testfactory = TestFactory(testconf) | ||
| 178 | + except TestFactoryException as exc: | ||
| 179 | + logger.critical(exc) | ||
| 180 | + raise AppException('Failed to create test factory!') from exc | ||
| 181 | + | ||
| 182 | + logger.info('Test factory ready. No errors found.') | ||
| 183 | + | ||
| 184 | + # ------------------------------------------------------------------------ | ||
| 185 | + def _pregenerate_tests(self, num): | ||
| 186 | + for _ in range(num): | ||
| 187 | + event_loop = asyncio.get_event_loop() | ||
| 188 | + test = event_loop.run_until_complete(self.testfactory.generate()) | ||
| 189 | + self.pregenerated_tests.append(test) | ||
| 190 | + | ||
| 191 | + # ------------------------------------------------------------------------ | ||
| 178 | async def generate_test(self, uid): | 192 | async def generate_test(self, uid): |
| 179 | '''generate a test for a given student''' | 193 | '''generate a test for a given student''' |
| 180 | if uid in self.online: | 194 | if uid in self.online: |
| 181 | - logger.info('"%s" generating new test.', uid) | 195 | + try: |
| 196 | + test = self.pregenerated_tests.pop() | ||
| 197 | + except IndexError: | ||
| 198 | + logger.info('"%s" generating new test.', uid) | ||
| 199 | + test = await self.testfactory.generate() # student_id) FIXME | ||
| 200 | + else: | ||
| 201 | + logger.info('"%s" using pregenerated test.', uid) | ||
| 202 | + | ||
| 182 | student_id = self.online[uid]['student'] # {number, name} | 203 | student_id = self.online[uid]['student'] # {number, name} |
| 183 | - test = await self.testfactory.generate(student_id) | 204 | + test.start(student_id) |
| 184 | self.online[uid]['test'] = test | 205 | self.online[uid]['test'] = test |
| 185 | logger.info('"%s" test is ready.', uid) | 206 | logger.info('"%s" test is ready.', uid) |
| 186 | return self.online[uid]['test'] | 207 | return self.online[uid]['test'] |
| 187 | 208 | ||
| 188 | - # this implies an error in the code. should never be here! | ||
| 189 | - logger.critical('"%s" offline, can\'t generate test', uid) | 209 | + # this implies an error in the program, code should be unreachable! |
| 210 | + logger.critical('"%s" is offline, can\'t generate test', uid) | ||
| 190 | 211 | ||
| 191 | # ------------------------------------------------------------------------ | 212 | # ------------------------------------------------------------------------ |
| 192 | async def correct_test(self, uid, ans): | 213 | async def correct_test(self, uid, ans): |
| @@ -271,11 +292,11 @@ class App(): | @@ -271,11 +292,11 @@ class App(): | ||
| 271 | '''handles browser events the occur during the test''' | 292 | '''handles browser events the occur during the test''' |
| 272 | if cmd == 'focus': | 293 | if cmd == 'focus': |
| 273 | if value: | 294 | if value: |
| 274 | - self.focus_student(uid) | 295 | + self._focus_student(uid) |
| 275 | else: | 296 | else: |
| 276 | - self.unfocus_student(uid) | 297 | + self._unfocus_student(uid) |
| 277 | elif cmd == 'size': | 298 | elif cmd == 'size': |
| 278 | - self.set_screen_area(uid, value) | 299 | + self._set_screen_area(uid, value) |
| 279 | 300 | ||
| 280 | # ------------------------------------------------------------------------ | 301 | # ------------------------------------------------------------------------ |
| 281 | # --- GETTERS | 302 | # --- GETTERS |
| @@ -297,11 +318,11 @@ class App(): | @@ -297,11 +318,11 @@ class App(): | ||
| 297 | 318 | ||
| 298 | cols = ['Aluno', 'Início'] + \ | 319 | cols = ['Aluno', 'Início'] + \ |
| 299 | [r for question in self.testfactory['questions'] | 320 | [r for question in self.testfactory['questions'] |
| 300 | - for r in question['ref']] | 321 | + for r in question['ref']] |
| 301 | 322 | ||
| 302 | tests = {} | 323 | tests = {} |
| 303 | - for q in grades: | ||
| 304 | - student, qref, qgrade = q[:2], q[2], q[3] | 324 | + for question in grades: |
| 325 | + student, qref, qgrade = question[:2], *question[2:] | ||
| 305 | tests.setdefault(student, {})[qref] = qgrade | 326 | tests.setdefault(student, {})[qref] = qgrade |
| 306 | 327 | ||
| 307 | rows = [{'Aluno': test[0], 'Início': test[1], **q} | 328 | rows = [{'Aluno': test[0], 'Início': test[1], **q} |
| @@ -351,13 +372,6 @@ class App(): | @@ -351,13 +372,6 @@ class App(): | ||
| 351 | .filter_by(id=test_id)\ | 372 | .filter_by(id=test_id)\ |
| 352 | .scalar() | 373 | .scalar() |
| 353 | 374 | ||
| 354 | - def get_all_students(self): | ||
| 355 | - '''get all students from database''' | ||
| 356 | - with self.db_session() as sess: | ||
| 357 | - return sess.query(Student.id, Student.name, Student.password)\ | ||
| 358 | - .filter(Student.id != '0')\ | ||
| 359 | - .order_by(Student.id) | ||
| 360 | - | ||
| 361 | def get_student_grades_from_test(self, uid, testid): | 375 | def get_student_grades_from_test(self, uid, testid): |
| 362 | '''get grades of student for a given testid''' | 376 | '''get grades of student for a given testid''' |
| 363 | with self.db_session() as sess: | 377 | with self.db_session() as sess: |
| @@ -380,7 +394,15 @@ class App(): | @@ -380,7 +394,15 @@ class App(): | ||
| 380 | 'area': self.area.get(uid, None), | 394 | 'area': self.area.get(uid, None), |
| 381 | 'grades': self.get_student_grades_from_test( | 395 | 'grades': self.get_student_grades_from_test( |
| 382 | uid, self.testfactory['ref']) | 396 | uid, self.testfactory['ref']) |
| 383 | - } for uid, name, pw in self.get_all_students()] | 397 | + } for uid, name, pw in self._get_all_students()] |
| 398 | + | ||
| 399 | + # --- private methods ---------------------------------------------------- | ||
| 400 | + def _get_all_students(self): | ||
| 401 | + '''get all students from database''' | ||
| 402 | + with self.db_session() as sess: | ||
| 403 | + return sess.query(Student.id, Student.name, Student.password)\ | ||
| 404 | + .filter(Student.id != '0')\ | ||
| 405 | + .order_by(Student.id) | ||
| 384 | 406 | ||
| 385 | # def get_allowed_students(self): | 407 | # def get_allowed_students(self): |
| 386 | # # set of 'uid' allowed to login | 408 | # # set of 'uid' allowed to login |
| @@ -409,30 +431,31 @@ class App(): | @@ -409,30 +431,31 @@ class App(): | ||
| 409 | 431 | ||
| 410 | def allow_all_students(self): | 432 | def allow_all_students(self): |
| 411 | '''allow all students to login''' | 433 | '''allow all students to login''' |
| 412 | - logger.info('Allowing all students...') | ||
| 413 | - self.allowed.update(s[0] for s in self.get_all_students()) | 434 | + all_students = self._get_all_students() |
| 435 | + self.allowed.update(s[0] for s in all_students) | ||
| 436 | + logger.info('Allowed all students.') | ||
| 414 | 437 | ||
| 415 | def deny_all_students(self): | 438 | def deny_all_students(self): |
| 416 | '''deny all students to login''' | 439 | '''deny all students to login''' |
| 417 | logger.info('Denying all students...') | 440 | logger.info('Denying all students...') |
| 418 | self.allowed.clear() | 441 | self.allowed.clear() |
| 419 | 442 | ||
| 420 | - def focus_student(self, uid): | 443 | + def _focus_student(self, uid): |
| 421 | '''set student in focus state''' | 444 | '''set student in focus state''' |
| 422 | self.unfocus.discard(uid) | 445 | self.unfocus.discard(uid) |
| 423 | logger.info('"%s" focus', uid) | 446 | logger.info('"%s" focus', uid) |
| 424 | 447 | ||
| 425 | - def unfocus_student(self, uid): | 448 | + def _unfocus_student(self, uid): |
| 426 | '''set student in unfocus state''' | 449 | '''set student in unfocus state''' |
| 427 | self.unfocus.add(uid) | 450 | self.unfocus.add(uid) |
| 428 | logger.info('"%s" unfocus', uid) | 451 | logger.info('"%s" unfocus', uid) |
| 429 | 452 | ||
| 430 | - def set_screen_area(self, uid, sizes): | 453 | + def _set_screen_area(self, uid, sizes): |
| 431 | '''set current browser area as detected in resize event''' | 454 | '''set current browser area as detected in resize event''' |
| 432 | scr_y, scr_x, win_y, win_x = sizes | 455 | scr_y, scr_x, win_y, win_x = sizes |
| 433 | area = win_x * win_y / (scr_x * scr_y) * 100 | 456 | area = win_x * win_y / (scr_x * scr_y) * 100 |
| 434 | self.area[uid] = area | 457 | self.area[uid] = area |
| 435 | - logger.info('"%s": area=%g%%, window=%dx%d, screen=%dx%d', | 458 | + logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', |
| 436 | uid, area, win_x, win_y, scr_x, scr_y) | 459 | uid, area, win_x, win_y, scr_x, scr_y) |
| 437 | 460 | ||
| 438 | async def update_student_password(self, uid, password=''): | 461 | async def update_student_password(self, uid, password=''): |
perguntations/initdb.py
perguntations/main.py
| @@ -15,10 +15,10 @@ import sys | @@ -15,10 +15,10 @@ import sys | ||
| 15 | # from typing import Any, Dict | 15 | # from typing import Any, Dict |
| 16 | 16 | ||
| 17 | # this project | 17 | # this project |
| 18 | -from .app import App, AppException | ||
| 19 | -from .serve import run_webserver | ||
| 20 | -from .tools import load_yaml | ||
| 21 | -from . import APP_NAME, APP_VERSION | 18 | +from perguntations.app import App, AppException |
| 19 | +from perguntations.serve import run_webserver | ||
| 20 | +from perguntations.tools import load_yaml | ||
| 21 | +from perguntations import APP_NAME, APP_VERSION | ||
| 22 | 22 | ||
| 23 | 23 | ||
| 24 | # ---------------------------------------------------------------------------- | 24 | # ---------------------------------------------------------------------------- |
| @@ -123,9 +123,8 @@ def main(): | @@ -123,9 +123,8 @@ def main(): | ||
| 123 | 'review': args.review, | 123 | 'review': args.review, |
| 124 | } | 124 | } |
| 125 | 125 | ||
| 126 | - # testapp = App(config) | ||
| 127 | try: | 126 | try: |
| 128 | - testapp = App(config) | 127 | + app = App(config) |
| 129 | except AppException: | 128 | except AppException: |
| 130 | logging.critical('Failed to start application.') | 129 | logging.critical('Failed to start application.') |
| 131 | sys.exit(-1) | 130 | sys.exit(-1) |
| @@ -145,8 +144,7 @@ def main(): | @@ -145,8 +144,7 @@ def main(): | ||
| 145 | sys.exit(-1) | 144 | sys.exit(-1) |
| 146 | 145 | ||
| 147 | # --- run webserver ---------------------------------------------------- | 146 | # --- run webserver ---------------------------------------------------- |
| 148 | - run_webserver(app=testapp, ssl_opt=ssl_opt, port=args.port, | ||
| 149 | - debug=args.debug) | 147 | + run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug) |
| 150 | 148 | ||
| 151 | 149 | ||
| 152 | # ---------------------------------------------------------------------------- | 150 | # ---------------------------------------------------------------------------- |
perguntations/parser_markdown.py
| 1 | + | ||
| 1 | ''' | 2 | ''' |
| 2 | Parse markdown and generate HTML | 3 | Parse markdown and generate HTML |
| 3 | Includes support for LaTeX formulas | 4 | Includes support for LaTeX formulas |
| @@ -25,12 +26,19 @@ logger = logging.getLogger(__name__) | @@ -25,12 +26,19 @@ logger = logging.getLogger(__name__) | ||
| 25 | # Block math: $$x$$ or \begin{equation}x\end{equation} | 26 | # Block math: $$x$$ or \begin{equation}x\end{equation} |
| 26 | # ------------------------------------------------------------------------- | 27 | # ------------------------------------------------------------------------- |
| 27 | class MathBlockGrammar(mistune.BlockGrammar): | 28 | class MathBlockGrammar(mistune.BlockGrammar): |
| 29 | + ''' | ||
| 30 | + match block math $$x$$ and math environments begin{} end{} | ||
| 31 | + ''' | ||
| 32 | + # pylint: disable=too-few-public-methods | ||
| 28 | block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) | 33 | block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) |
| 29 | latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", | 34 | latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", |
| 30 | re.DOTALL) | 35 | re.DOTALL) |
| 31 | 36 | ||
| 32 | 37 | ||
| 33 | class MathBlockLexer(mistune.BlockLexer): | 38 | class MathBlockLexer(mistune.BlockLexer): |
| 39 | + ''' | ||
| 40 | + parser for block math and latex environment | ||
| 41 | + ''' | ||
| 34 | default_rules = ['block_math', 'latex_environment'] \ | 42 | default_rules = ['block_math', 'latex_environment'] \ |
| 35 | + mistune.BlockLexer.default_rules | 43 | + mistune.BlockLexer.default_rules |
| 36 | 44 | ||
| @@ -56,12 +64,19 @@ class MathBlockLexer(mistune.BlockLexer): | @@ -56,12 +64,19 @@ class MathBlockLexer(mistune.BlockLexer): | ||
| 56 | 64 | ||
| 57 | 65 | ||
| 58 | class MathInlineGrammar(mistune.InlineGrammar): | 66 | class MathInlineGrammar(mistune.InlineGrammar): |
| 67 | + ''' | ||
| 68 | + match inline math $x$, block math $$x$$ and text | ||
| 69 | + ''' | ||
| 70 | + # pylint: disable=too-few-public-methods | ||
| 59 | math = re.compile(r"^\$(.+?)\$", re.DOTALL) | 71 | math = re.compile(r"^\$(.+?)\$", re.DOTALL) |
| 60 | block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL) | 72 | block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL) |
| 61 | text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)') | 73 | text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)') |
| 62 | 74 | ||
| 63 | 75 | ||
| 64 | class MathInlineLexer(mistune.InlineLexer): | 76 | class MathInlineLexer(mistune.InlineLexer): |
| 77 | + ''' | ||
| 78 | + render output math | ||
| 79 | + ''' | ||
| 65 | default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules | 80 | default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules |
| 66 | 81 | ||
| 67 | def __init__(self, renderer, rules=None, **kwargs): | 82 | def __init__(self, renderer, rules=None, **kwargs): |
| @@ -70,13 +85,18 @@ class MathInlineLexer(mistune.InlineLexer): | @@ -70,13 +85,18 @@ class MathInlineLexer(mistune.InlineLexer): | ||
| 70 | super().__init__(renderer, rules, **kwargs) | 85 | super().__init__(renderer, rules, **kwargs) |
| 71 | 86 | ||
| 72 | def output_math(self, math): | 87 | def output_math(self, math): |
| 88 | + '''render inline math''' | ||
| 73 | return self.renderer.inline_math(math.group(1)) | 89 | return self.renderer.inline_math(math.group(1)) |
| 74 | 90 | ||
| 75 | def output_block_math(self, math): | 91 | def output_block_math(self, math): |
| 92 | + '''render block math''' | ||
| 76 | return self.renderer.block_math(math.group(1)) | 93 | return self.renderer.block_math(math.group(1)) |
| 77 | 94 | ||
| 78 | 95 | ||
| 79 | class MarkdownWithMath(mistune.Markdown): | 96 | class MarkdownWithMath(mistune.Markdown): |
| 97 | + ''' | ||
| 98 | + render ouput latex | ||
| 99 | + ''' | ||
| 80 | def __init__(self, renderer, **kwargs): | 100 | def __init__(self, renderer, **kwargs): |
| 81 | if 'inline' not in kwargs: | 101 | if 'inline' not in kwargs: |
| 82 | kwargs['inline'] = MathInlineLexer | 102 | kwargs['inline'] = MathInlineLexer |
| @@ -85,19 +105,25 @@ class MarkdownWithMath(mistune.Markdown): | @@ -85,19 +105,25 @@ class MarkdownWithMath(mistune.Markdown): | ||
| 85 | super().__init__(renderer, **kwargs) | 105 | super().__init__(renderer, **kwargs) |
| 86 | 106 | ||
| 87 | def output_block_math(self): | 107 | def output_block_math(self): |
| 108 | + '''render block math''' | ||
| 88 | return self.renderer.block_math(self.token['text']) | 109 | return self.renderer.block_math(self.token['text']) |
| 89 | 110 | ||
| 90 | def output_latex_environment(self): | 111 | def output_latex_environment(self): |
| 112 | + '''render latex environment''' | ||
| 91 | return self.renderer.latex_environment(self.token['name'], | 113 | return self.renderer.latex_environment(self.token['name'], |
| 92 | self.token['text']) | 114 | self.token['text']) |
| 93 | 115 | ||
| 94 | 116 | ||
| 95 | class HighlightRenderer(mistune.Renderer): | 117 | class HighlightRenderer(mistune.Renderer): |
| 118 | + ''' | ||
| 119 | + images, tables, block code | ||
| 120 | + ''' | ||
| 96 | def __init__(self, qref='.'): | 121 | def __init__(self, qref='.'): |
| 97 | super().__init__(escape=True) | 122 | super().__init__(escape=True) |
| 98 | self.qref = qref | 123 | self.qref = qref |
| 99 | 124 | ||
| 100 | def block_code(self, code, lang='text'): | 125 | def block_code(self, code, lang='text'): |
| 126 | + '''render code block with syntax highlight''' | ||
| 101 | try: | 127 | try: |
| 102 | lexer = get_lexer_by_name(lang, stripall=False) | 128 | lexer = get_lexer_by_name(lang, stripall=False) |
| 103 | except Exception: | 129 | except Exception: |
| @@ -107,6 +133,7 @@ class HighlightRenderer(mistune.Renderer): | @@ -107,6 +133,7 @@ class HighlightRenderer(mistune.Renderer): | ||
| 107 | return highlight(code, lexer, formatter) | 133 | return highlight(code, lexer, formatter) |
| 108 | 134 | ||
| 109 | def table(self, header, body): | 135 | def table(self, header, body): |
| 136 | + '''render table''' | ||
| 110 | return '<table class="table table-sm"><thead class="thead-light">' \ | 137 | return '<table class="table table-sm"><thead class="thead-light">' \ |
| 111 | + header + '</thead><tbody>' + body + '</tbody></table>' | 138 | + header + '</thead><tbody>' + body + '</tbody></table>' |
| 112 | 139 | ||
| @@ -141,14 +168,17 @@ class HighlightRenderer(mistune.Renderer): | @@ -141,14 +168,17 @@ class HighlightRenderer(mistune.Renderer): | ||
| 141 | # Pass math through unaltered - mathjax does the rendering in the browser | 168 | # Pass math through unaltered - mathjax does the rendering in the browser |
| 142 | def block_math(self, text): | 169 | def block_math(self, text): |
| 143 | '''bypass block math''' | 170 | '''bypass block math''' |
| 171 | + # pylint: disable=no-self-use | ||
| 144 | return fr'$$ {text} $$' | 172 | return fr'$$ {text} $$' |
| 145 | 173 | ||
| 146 | def latex_environment(self, name, text): | 174 | def latex_environment(self, name, text): |
| 147 | '''bypass latex environment''' | 175 | '''bypass latex environment''' |
| 176 | + # pylint: disable=no-self-use | ||
| 148 | return fr'\begin{{{name}}} {text} \end{{{name}}}' | 177 | return fr'\begin{{{name}}} {text} \end{{{name}}}' |
| 149 | 178 | ||
| 150 | def inline_math(self, text): | 179 | def inline_math(self, text): |
| 151 | '''bypass inline math''' | 180 | '''bypass inline math''' |
| 181 | + # pylint: disable=no-self-use | ||
| 152 | return fr'$$$ {text} $$$' | 182 | return fr'$$$ {text} $$$' |
| 153 | 183 | ||
| 154 | 184 |
perguntations/questions.py
| @@ -13,7 +13,7 @@ from typing import Any, Dict, NewType | @@ -13,7 +13,7 @@ from typing import Any, Dict, NewType | ||
| 13 | import uuid | 13 | import uuid |
| 14 | 14 | ||
| 15 | # this project | 15 | # this project |
| 16 | -from .tools import run_script, run_script_async | 16 | +from perguntations.tools import run_script, run_script_async |
| 17 | 17 | ||
| 18 | # setup logger for this module | 18 | # setup logger for this module |
| 19 | logger = logging.getLogger(__name__) | 19 | logger = logging.getLogger(__name__) |
| @@ -110,10 +110,10 @@ class QuestionRadio(Question): | @@ -110,10 +110,10 @@ class QuestionRadio(Question): | ||
| 110 | # make sure is a list of floats | 110 | # make sure is a list of floats |
| 111 | try: | 111 | try: |
| 112 | self['correct'] = [float(x) for x in self['correct']] | 112 | self['correct'] = [float(x) for x in self['correct']] |
| 113 | - except (ValueError, TypeError): | 113 | + except (ValueError, TypeError) as exc: |
| 114 | msg = (f'Correct list must contain numbers [0.0, 1.0] or ' | 114 | msg = (f'Correct list must contain numbers [0.0, 1.0] or ' |
| 115 | f'booleans in "{self["ref"]}"') | 115 | f'booleans in "{self["ref"]}"') |
| 116 | - raise QuestionException(msg) | 116 | + raise QuestionException(msg) from exc |
| 117 | 117 | ||
| 118 | # check grade boundaries | 118 | # check grade boundaries |
| 119 | if self['discount'] and not all(0.0 <= x <= 1.0 | 119 | if self['discount'] and not all(0.0 <= x <= 1.0 |
| @@ -217,10 +217,10 @@ class QuestionCheckbox(Question): | @@ -217,10 +217,10 @@ class QuestionCheckbox(Question): | ||
| 217 | # make sure is a list of floats | 217 | # make sure is a list of floats |
| 218 | try: | 218 | try: |
| 219 | self['correct'] = [float(x) for x in self['correct']] | 219 | self['correct'] = [float(x) for x in self['correct']] |
| 220 | - except (ValueError, TypeError): | 220 | + except (ValueError, TypeError) as exc: |
| 221 | msg = (f'Correct list must contain numbers or ' | 221 | msg = (f'Correct list must contain numbers or ' |
| 222 | f'booleans in "{self["ref"]}"') | 222 | f'booleans in "{self["ref"]}"') |
| 223 | - raise QuestionException(msg) | 223 | + raise QuestionException(msg) from exc |
| 224 | 224 | ||
| 225 | # check grade boundaries | 225 | # check grade boundaries |
| 226 | if self['discount'] and not all(0.0 <= x <= 1.0 | 226 | if self['discount'] and not all(0.0 <= x <= 1.0 |
| @@ -379,9 +379,9 @@ class QuestionTextRegex(Question): | @@ -379,9 +379,9 @@ class QuestionTextRegex(Question): | ||
| 379 | # converts patterns to compiled versions | 379 | # converts patterns to compiled versions |
| 380 | try: | 380 | try: |
| 381 | self['correct'] = [re.compile(a) for a in self['correct']] | 381 | self['correct'] = [re.compile(a) for a in self['correct']] |
| 382 | - except Exception: | 382 | + except Exception as exc: |
| 383 | msg = f'Failed to compile regex in "{self["ref"]}"' | 383 | msg = f'Failed to compile regex in "{self["ref"]}"' |
| 384 | - raise QuestionException(msg) | 384 | + raise QuestionException(msg) from exc |
| 385 | 385 | ||
| 386 | # ------------------------------------------------------------------------ | 386 | # ------------------------------------------------------------------------ |
| 387 | def correct(self) -> None: | 387 | def correct(self) -> None: |
| @@ -430,10 +430,10 @@ class QuestionNumericInterval(Question): | @@ -430,10 +430,10 @@ class QuestionNumericInterval(Question): | ||
| 430 | 430 | ||
| 431 | try: | 431 | try: |
| 432 | self['correct'] = [float(n) for n in self['correct']] | 432 | self['correct'] = [float(n) for n in self['correct']] |
| 433 | - except Exception: | 433 | + except Exception as exc: |
| 434 | msg = (f'Numeric interval must be a list with two numbers, in ' | 434 | msg = (f'Numeric interval must be a list with two numbers, in ' |
| 435 | f'{self["ref"]}') | 435 | f'{self["ref"]}') |
| 436 | - raise QuestionException(msg) | 436 | + raise QuestionException(msg) from exc |
| 437 | 437 | ||
| 438 | # invalid | 438 | # invalid |
| 439 | else: | 439 | else: |
perguntations/serve.py
| 1 | #!/usr/bin/env python3 | 1 | #!/usr/bin/env python3 |
| 2 | 2 | ||
| 3 | ''' | 3 | ''' |
| 4 | -Handles the web and html part of the application interface. | ||
| 5 | -The tornadoweb framework is used. | 4 | +Handles the web, http & html part of the application interface. |
| 5 | +Uses the tornadoweb framework. | ||
| 6 | ''' | 6 | ''' |
| 7 | 7 | ||
| 8 | 8 | ||
| @@ -40,8 +40,8 @@ class WebApplication(tornado.web.Application): | @@ -40,8 +40,8 @@ class WebApplication(tornado.web.Application): | ||
| 40 | (r'/review', ReviewHandler), | 40 | (r'/review', ReviewHandler), |
| 41 | (r'/admin', AdminHandler), | 41 | (r'/admin', AdminHandler), |
| 42 | (r'/file', FileHandler), | 42 | (r'/file', FileHandler), |
| 43 | - # (r'/root', MainHandler), # FIXME | ||
| 44 | - # (r'/ws', AdminSocketHandler), | 43 | + # (r'/root', MainHandler), |
| 44 | + # (r'/ws', AdminSocketHandler), | ||
| 45 | (r'/adminwebservice', AdminWebservice), | 45 | (r'/adminwebservice', AdminWebservice), |
| 46 | (r'/studentwebservice', StudentWebservice), | 46 | (r'/studentwebservice', StudentWebservice), |
| 47 | (r'/', RootHandler), | 47 | (r'/', RootHandler), |
| @@ -66,7 +66,7 @@ def admin_only(func): | @@ -66,7 +66,7 @@ def admin_only(func): | ||
| 66 | Decorator used to restrict access to the administrator. | 66 | Decorator used to restrict access to the administrator. |
| 67 | Example: | 67 | Example: |
| 68 | 68 | ||
| 69 | - @admin_only() | 69 | + @admin_only |
| 70 | def get(self): ... | 70 | def get(self): ... |
| 71 | ''' | 71 | ''' |
| 72 | @functools.wraps(func) | 72 | @functools.wraps(func) |
| @@ -78,6 +78,7 @@ def admin_only(func): | @@ -78,6 +78,7 @@ def admin_only(func): | ||
| 78 | 78 | ||
| 79 | 79 | ||
| 80 | # ---------------------------------------------------------------------------- | 80 | # ---------------------------------------------------------------------------- |
| 81 | +# pylint: disable=abstract-method | ||
| 81 | class BaseHandler(tornado.web.RequestHandler): | 82 | class BaseHandler(tornado.web.RequestHandler): |
| 82 | ''' | 83 | ''' |
| 83 | Handlers should inherit this one instead of tornado.web.RequestHandler. | 84 | Handlers should inherit this one instead of tornado.web.RequestHandler. |
| @@ -87,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -87,7 +88,7 @@ class BaseHandler(tornado.web.RequestHandler): | ||
| 87 | 88 | ||
| 88 | @property | 89 | @property |
| 89 | def testapp(self): | 90 | def testapp(self): |
| 90 | - '''simplifies access to the application''' | 91 | + '''simplifies access to the application a little bit''' |
| 91 | return self.application.testapp | 92 | return self.application.testapp |
| 92 | 93 | ||
| 93 | def get_current_user(self): | 94 | def get_current_user(self): |
| @@ -158,6 +159,8 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -158,6 +159,8 @@ class BaseHandler(tornado.web.RequestHandler): | ||
| 158 | # AdminSocketHandler.update_cache(chat) # store msgs | 159 | # AdminSocketHandler.update_cache(chat) # store msgs |
| 159 | # AdminSocketHandler.send_updates(chat) # send to clients | 160 | # AdminSocketHandler.send_updates(chat) # send to clients |
| 160 | 161 | ||
| 162 | +# ---------------------------------------------------------------------------- | ||
| 163 | +# pylint: disable=abstract-method | ||
| 161 | class StudentWebservice(BaseHandler): | 164 | class StudentWebservice(BaseHandler): |
| 162 | ''' | 165 | ''' |
| 163 | Receive ajax from students in the test in response from focus, unfocus and | 166 | Receive ajax from students in the test in response from focus, unfocus and |
| @@ -174,6 +177,7 @@ class StudentWebservice(BaseHandler): | @@ -174,6 +177,7 @@ class StudentWebservice(BaseHandler): | ||
| 174 | 177 | ||
| 175 | 178 | ||
| 176 | # ---------------------------------------------------------------------------- | 179 | # ---------------------------------------------------------------------------- |
| 180 | +# pylint: disable=abstract-method | ||
| 177 | class AdminWebservice(BaseHandler): | 181 | class AdminWebservice(BaseHandler): |
| 178 | ''' | 182 | ''' |
| 179 | Receive ajax requests from admin | 183 | Receive ajax requests from admin |
| @@ -202,6 +206,7 @@ class AdminWebservice(BaseHandler): | @@ -202,6 +206,7 @@ class AdminWebservice(BaseHandler): | ||
| 202 | 206 | ||
| 203 | 207 | ||
| 204 | # ---------------------------------------------------------------------------- | 208 | # ---------------------------------------------------------------------------- |
| 209 | +# pylint: disable=abstract-method | ||
| 205 | class AdminHandler(BaseHandler): | 210 | class AdminHandler(BaseHandler): |
| 206 | '''Handle /admin''' | 211 | '''Handle /admin''' |
| 207 | 212 | ||
| @@ -260,6 +265,7 @@ class AdminHandler(BaseHandler): | @@ -260,6 +265,7 @@ class AdminHandler(BaseHandler): | ||
| 260 | 265 | ||
| 261 | 266 | ||
| 262 | # ---------------------------------------------------------------------------- | 267 | # ---------------------------------------------------------------------------- |
| 268 | +# pylint: disable=abstract-method | ||
| 263 | class LoginHandler(BaseHandler): | 269 | class LoginHandler(BaseHandler): |
| 264 | '''Handle /login''' | 270 | '''Handle /login''' |
| 265 | 271 | ||
| @@ -282,6 +288,7 @@ class LoginHandler(BaseHandler): | @@ -282,6 +288,7 @@ class LoginHandler(BaseHandler): | ||
| 282 | 288 | ||
| 283 | 289 | ||
| 284 | # ---------------------------------------------------------------------------- | 290 | # ---------------------------------------------------------------------------- |
| 291 | +# pylint: disable=abstract-method | ||
| 285 | class LogoutHandler(BaseHandler): | 292 | class LogoutHandler(BaseHandler): |
| 286 | '''Handle /logout''' | 293 | '''Handle /logout''' |
| 287 | 294 | ||
| @@ -296,6 +303,7 @@ class LogoutHandler(BaseHandler): | @@ -296,6 +303,7 @@ class LogoutHandler(BaseHandler): | ||
| 296 | 303 | ||
| 297 | 304 | ||
| 298 | # ---------------------------------------------------------------------------- | 305 | # ---------------------------------------------------------------------------- |
| 306 | +# pylint: disable=abstract-method | ||
| 299 | class RootHandler(BaseHandler): | 307 | class RootHandler(BaseHandler): |
| 300 | ''' | 308 | ''' |
| 301 | Handles / to redirect students and admin to /test and /admin, resp. | 309 | Handles / to redirect students and admin to /test and /admin, resp. |
| @@ -315,6 +323,7 @@ class RootHandler(BaseHandler): | @@ -315,6 +323,7 @@ class RootHandler(BaseHandler): | ||
| 315 | # ---------------------------------------------------------------------------- | 323 | # ---------------------------------------------------------------------------- |
| 316 | # Serves files from the /public subdir of the topics. | 324 | # Serves files from the /public subdir of the topics. |
| 317 | # ---------------------------------------------------------------------------- | 325 | # ---------------------------------------------------------------------------- |
| 326 | +# pylint: disable=abstract-method | ||
| 318 | class FileHandler(BaseHandler): | 327 | class FileHandler(BaseHandler): |
| 319 | ''' | 328 | ''' |
| 320 | Handles static files from questions like images, etc. | 329 | Handles static files from questions like images, etc. |
| @@ -366,6 +375,7 @@ class FileHandler(BaseHandler): | @@ -366,6 +375,7 @@ class FileHandler(BaseHandler): | ||
| 366 | # ---------------------------------------------------------------------------- | 375 | # ---------------------------------------------------------------------------- |
| 367 | # Test shown to students | 376 | # Test shown to students |
| 368 | # ---------------------------------------------------------------------------- | 377 | # ---------------------------------------------------------------------------- |
| 378 | +# pylint: disable=abstract-method | ||
| 369 | class TestHandler(BaseHandler): | 379 | class TestHandler(BaseHandler): |
| 370 | ''' | 380 | ''' |
| 371 | Generates test to student. | 381 | Generates test to student. |
| @@ -373,6 +383,7 @@ class TestHandler(BaseHandler): | @@ -373,6 +383,7 @@ class TestHandler(BaseHandler): | ||
| 373 | ''' | 383 | ''' |
| 374 | 384 | ||
| 375 | _templates = { | 385 | _templates = { |
| 386 | + # -- question templates -- | ||
| 376 | 'radio': 'question-radio.html', | 387 | 'radio': 'question-radio.html', |
| 377 | 'checkbox': 'question-checkbox.html', | 388 | 'checkbox': 'question-checkbox.html', |
| 378 | 'text': 'question-text.html', | 389 | 'text': 'question-text.html', |
| @@ -396,6 +407,7 @@ class TestHandler(BaseHandler): | @@ -396,6 +407,7 @@ class TestHandler(BaseHandler): | ||
| 396 | test = self.testapp.get_student_test(uid) # reloading returns same test | 407 | test = self.testapp.get_student_test(uid) # reloading returns same test |
| 397 | if test is None: | 408 | if test is None: |
| 398 | test = await self.testapp.generate_test(uid) | 409 | test = await self.testapp.generate_test(uid) |
| 410 | + | ||
| 399 | self.render('test.html', t=test, md=md_to_html, templ=self._templates) | 411 | self.render('test.html', t=test, md=md_to_html, templ=self._templates) |
| 400 | 412 | ||
| 401 | # --- POST | 413 | # --- POST |
| @@ -438,20 +450,8 @@ class TestHandler(BaseHandler): | @@ -438,20 +450,8 @@ class TestHandler(BaseHandler): | ||
| 438 | self.render('grade.html', t=test, allgrades=allgrades) | 450 | self.render('grade.html', t=test, allgrades=allgrades) |
| 439 | 451 | ||
| 440 | 452 | ||
| 441 | -# ---------------------------------------------------------------------------- | ||
| 442 | -# FIXME should be a post in the test with command giveup instead of correct... | ||
| 443 | -# class GiveupHandler(BaseHandler): | ||
| 444 | -# @tornado.web.authenticated | ||
| 445 | -# def get(self): | ||
| 446 | -# uid = self.current_user | ||
| 447 | -# t = self.testapp.giveup_test(uid) | ||
| 448 | -# self.testapp.logout(uid) | ||
| 449 | - | ||
| 450 | -# # --- Show result to student | ||
| 451 | -# self.render('grade.html', t=t, allgrades=self.testapp.get_student_grades_from_all_tests(uid)) | ||
| 452 | - | ||
| 453 | - | ||
| 454 | # --- REVIEW ----------------------------------------------------------------- | 453 | # --- REVIEW ----------------------------------------------------------------- |
| 454 | +# pylint: disable=abstract-method | ||
| 455 | class ReviewHandler(BaseHandler): | 455 | class ReviewHandler(BaseHandler): |
| 456 | ''' | 456 | ''' |
| 457 | Show test for review | 457 | Show test for review |
| @@ -488,18 +488,20 @@ class ReviewHandler(BaseHandler): | @@ -488,18 +488,20 @@ class ReviewHandler(BaseHandler): | ||
| 488 | with open(path.expanduser(fname)) as jsonfile: | 488 | with open(path.expanduser(fname)) as jsonfile: |
| 489 | test = json.load(jsonfile) | 489 | test = json.load(jsonfile) |
| 490 | except OSError: | 490 | except OSError: |
| 491 | - logging.error('Cannot open "%s" for review.', fname) | ||
| 492 | - raise tornado.web.HTTPError(404) # Not Found | 491 | + msg = f'Cannot open "{fname}" for review.' |
| 492 | + logging.error(msg) | ||
| 493 | + raise tornado.web.HTTPError(status_code=404, reason=msg) from None | ||
| 493 | except json.JSONDecodeError as exc: | 494 | except json.JSONDecodeError as exc: |
| 494 | - logging.error('JSON error in "%s": %s', fname, exc) | ||
| 495 | - raise tornado.web.HTTPError(404) # Not Found | 495 | + msg = f'JSON error in "{fname}": {exc}' |
| 496 | + logging.error(msg) | ||
| 497 | + raise tornado.web.HTTPError(status_code=404, reason=msg) | ||
| 496 | 498 | ||
| 497 | self.render('review.html', t=test, md=md_to_html, | 499 | self.render('review.html', t=test, md=md_to_html, |
| 498 | - templ=self._templates) | 500 | + templ=self._templates) |
| 499 | 501 | ||
| 500 | 502 | ||
| 501 | # ---------------------------------------------------------------------------- | 503 | # ---------------------------------------------------------------------------- |
| 502 | -def signal_handler(sig, frame): | 504 | +def signal_handler(*_): |
| 503 | ''' | 505 | ''' |
| 504 | Catches Ctrl-C and stops webserver | 506 | Catches Ctrl-C and stops webserver |
| 505 | ''' | 507 | ''' |
perguntations/static/js/admin.js
| @@ -119,7 +119,7 @@ $(document).ready(function() { | @@ -119,7 +119,7 @@ $(document).ready(function() { | ||
| 119 | var checked = d['allowed'] ? 'checked' : ''; | 119 | var checked = d['allowed'] ? 'checked' : ''; |
| 120 | var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : ''; | 120 | var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : ''; |
| 121 | var hora_inicio = d['start_time'] ? ' <span class="badge badge-success"><i class="fas fa-hourglass-start"></i> ' + d['start_time'].slice(11,16) + '</span>': ''; | 121 | var hora_inicio = d['start_time'] ? ' <span class="badge badge-success"><i class="fas fa-hourglass-start"></i> ' + d['start_time'].slice(11,16) + '</span>': ''; |
| 122 | - var unfocus = d['unfocus']? ' <span class="badge badge-danger">unfocus</span>' : ''; | 122 | + var unfocus = d['unfocus'] ? ' <span class="badge badge-danger">unfocus</span>' : ''; |
| 123 | var area = ''; | 123 | var area = ''; |
| 124 | if (d['start_time'] ) { | 124 | if (d['start_time'] ) { |
| 125 | if (d['area'] > 75) | 125 | if (d['area'] > 75) |
perguntations/templates/test.html
| @@ -44,7 +44,7 @@ | @@ -44,7 +44,7 @@ | ||
| 44 | <!-- ===================================================================== --> | 44 | <!-- ===================================================================== --> |
| 45 | <body> | 45 | <body> |
| 46 | <!-- ===================================================================== --> | 46 | <!-- ===================================================================== --> |
| 47 | -<div class="progress fixed-top" style="height: 60px; border-radius: 0px;"> | 47 | +<div class="progress fixed-top" style="height: 61px; border-radius: 0px;"> |
| 48 | <div class="progress-bar bg-secondary" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div> | 48 | <div class="progress-bar bg-secondary" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div> |
| 49 | </div> | 49 | </div> |
| 50 | 50 |
perguntations/test.py
| @@ -111,9 +111,9 @@ class TestFactory(dict): | @@ -111,9 +111,9 @@ class TestFactory(dict): | ||
| 111 | # check if all the questions can be correctly generated | 111 | # check if all the questions can be correctly generated |
| 112 | try: | 112 | try: |
| 113 | self.question_factory[question['ref']].generate() | 113 | self.question_factory[question['ref']].generate() |
| 114 | - except Exception: | 114 | + except Exception as exc: |
| 115 | msg = f'Failed to generate "{question["ref"]}"' | 115 | msg = f'Failed to generate "{question["ref"]}"' |
| 116 | - raise TestFactoryException(msg) | 116 | + raise TestFactoryException(msg) from exc |
| 117 | else: | 117 | else: |
| 118 | logger.info('%4d. "%s" Ok.', counter, question["ref"]) | 118 | logger.info('%4d. "%s" Ok.', counter, question["ref"]) |
| 119 | counter += 1 | 119 | counter += 1 |
| @@ -152,9 +152,9 @@ class TestFactory(dict): | @@ -152,9 +152,9 @@ class TestFactory(dict): | ||
| 152 | try: | 152 | try: |
| 153 | with open(testfile, 'w') as file: | 153 | with open(testfile, 'w') as file: |
| 154 | file.write('You can safely remove this file.') | 154 | file.write('You can safely remove this file.') |
| 155 | - except OSError: | 155 | + except OSError as exc: |
| 156 | msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | 156 | msg = f'Cannot write answers to directory "{self["answers_dir"]}"' |
| 157 | - raise TestFactoryException(msg) | 157 | + raise TestFactoryException(msg) from exc |
| 158 | 158 | ||
| 159 | def check_questions_directory(self): | 159 | def check_questions_directory(self): |
| 160 | '''Check if questions directory is missing or not accessible.''' | 160 | '''Check if questions directory is missing or not accessible.''' |
| @@ -223,16 +223,16 @@ class TestFactory(dict): | @@ -223,16 +223,16 @@ class TestFactory(dict): | ||
| 223 | self.check_grade_scaling() | 223 | self.check_grade_scaling() |
| 224 | 224 | ||
| 225 | # ------------------------------------------------------------------------ | 225 | # ------------------------------------------------------------------------ |
| 226 | - async def generate(self, student): | 226 | + async def generate(self): #, student): |
| 227 | ''' | 227 | ''' |
| 228 | Given a dictionary with a student dict {'name':'john', 'number': 123} | 228 | Given a dictionary with a student dict {'name':'john', 'number': 123} |
| 229 | returns instance of Test() for that particular student | 229 | returns instance of Test() for that particular student |
| 230 | ''' | 230 | ''' |
| 231 | 231 | ||
| 232 | # make list of questions | 232 | # make list of questions |
| 233 | - test = [] | ||
| 234 | - qnum = 1 # track question number | ||
| 235 | - nerr = 0 # count errors generating questions | 233 | + questions = [] |
| 234 | + qnum = 1 # track question number | ||
| 235 | + nerr = 0 # count errors during questions generation | ||
| 236 | 236 | ||
| 237 | for qlist in self['questions']: | 237 | for qlist in self['questions']: |
| 238 | # choose one question variant | 238 | # choose one question variant |
| @@ -255,28 +255,28 @@ class TestFactory(dict): | @@ -255,28 +255,28 @@ class TestFactory(dict): | ||
| 255 | question['number'] = qnum # counter for non informative panels | 255 | question['number'] = qnum # counter for non informative panels |
| 256 | qnum += 1 | 256 | qnum += 1 |
| 257 | 257 | ||
| 258 | - test.append(question) | 258 | + questions.append(question) |
| 259 | 259 | ||
| 260 | # setup scale | 260 | # setup scale |
| 261 | - total_points = sum(q['points'] for q in test) | 261 | + total_points = sum(q['points'] for q in questions) |
| 262 | 262 | ||
| 263 | if total_points > 0: | 263 | if total_points > 0: |
| 264 | # normalize question points to scale | 264 | # normalize question points to scale |
| 265 | if self['scale'] is not None: | 265 | if self['scale'] is not None: |
| 266 | scale_min, scale_max = self['scale'] | 266 | scale_min, scale_max = self['scale'] |
| 267 | - for question in test: | 267 | + for question in questions: |
| 268 | question['points'] *= (scale_max - scale_min) / total_points | 268 | question['points'] *= (scale_max - scale_min) / total_points |
| 269 | else: | 269 | else: |
| 270 | self['scale'] = [0, total_points] | 270 | self['scale'] = [0, total_points] |
| 271 | else: | 271 | else: |
| 272 | logger.warning('Total points is **ZERO**.') | 272 | logger.warning('Total points is **ZERO**.') |
| 273 | if self['scale'] is None: | 273 | if self['scale'] is None: |
| 274 | - self['scale'] = [0, 20] | 274 | + self['scale'] = [0, 20] # default |
| 275 | 275 | ||
| 276 | if nerr > 0: | 276 | if nerr > 0: |
| 277 | logger.error('%s errors found!', nerr) | 277 | logger.error('%s errors found!', nerr) |
| 278 | 278 | ||
| 279 | - # these will be copied to the test instance | 279 | + # copy these from the test configuratoin to each test instance |
| 280 | inherit = {'ref', 'title', 'database', 'answers_dir', | 280 | inherit = {'ref', 'title', 'database', 'answers_dir', |
| 281 | 'questions_dir', 'files', | 281 | 'questions_dir', 'files', |
| 282 | 'duration', 'autosubmit', | 282 | 'duration', 'autosubmit', |
| @@ -284,9 +284,7 @@ class TestFactory(dict): | @@ -284,9 +284,7 @@ class TestFactory(dict): | ||
| 284 | 'show_ref', 'debug', } | 284 | 'show_ref', 'debug', } |
| 285 | # NOT INCLUDED: testfile, allow_all, review | 285 | # NOT INCLUDED: testfile, allow_all, review |
| 286 | 286 | ||
| 287 | - return Test({ | ||
| 288 | - **{'student': student, 'questions': test}, | ||
| 289 | - **{k:self[k] for k in inherit}}) | 287 | + return Test({'questions': questions, **{k:self[k] for k in inherit}}) |
| 290 | 288 | ||
| 291 | # ------------------------------------------------------------------------ | 289 | # ------------------------------------------------------------------------ |
| 292 | def __repr__(self): | 290 | def __repr__(self): |
| @@ -301,8 +299,15 @@ class Test(dict): | @@ -301,8 +299,15 @@ class Test(dict): | ||
| 301 | ''' | 299 | ''' |
| 302 | 300 | ||
| 303 | # ------------------------------------------------------------------------ | 301 | # ------------------------------------------------------------------------ |
| 304 | - def __init__(self, d): | ||
| 305 | - super().__init__(d) | 302 | + # def __init__(self, d): |
| 303 | + # super().__init__(d) | ||
| 304 | + | ||
| 305 | + # ------------------------------------------------------------------------ | ||
| 306 | + def start(self, student): | ||
| 307 | + ''' | ||
| 308 | + Write student id in the test and register start time | ||
| 309 | + ''' | ||
| 310 | + self['student'] = student | ||
| 306 | self['start_time'] = datetime.now() | 311 | self['start_time'] = datetime.now() |
| 307 | self['finish_time'] = None | 312 | self['finish_time'] = None |
| 308 | self['state'] = 'ACTIVE' | 313 | self['state'] = 'ACTIVE' |
| @@ -346,5 +351,12 @@ class Test(dict): | @@ -346,5 +351,12 @@ class Test(dict): | ||
| 346 | self['finish_time'] = datetime.now() | 351 | self['finish_time'] = datetime.now() |
| 347 | self['state'] = 'QUIT' | 352 | self['state'] = 'QUIT' |
| 348 | self['grade'] = 0.0 | 353 | self['grade'] = 0.0 |
| 349 | - logger.info('Student %s: gave up.', self["student"]["number"]) | 354 | + # logger.info('Student %s: gave up.', self["student"]["number"]) |
| 350 | return self['grade'] | 355 | return self['grade'] |
| 356 | + | ||
| 357 | + # ------------------------------------------------------------------------ | ||
| 358 | + def __str__(self): | ||
| 359 | + return ('Test:\n' | ||
| 360 | + f' student: {self.get("student", "--")}\n' | ||
| 361 | + f' start_time: {self.get("start_time", "--")}\n' | ||
| 362 | + f' questions: {", ".join(q["ref"] for q in self["questions"])}\n') |