Commit 1d26fac2210c63fc7eb46239c9ddbc25969db2b7

Authored by Miguel Barão
1 parent 71a43de2
Exists in master and in 1 other branch dev

- updates BUGS.md and README.md.

- adds templates for loggers in config directory.
BUGS.md
1 1  
2 2 # BUGS
3 3  
4   -- ensure pip nao funciona no ubuntu?...
5   -- acrescentar logger.conf que sirva de base.
6   -- exception sqlalchemy relacionada com threads.
7 4 - quando se clica no texto de uma opcao, salta para outro lado na pagina.
8 5 - ordenacao das notas em /admin nao é numerica, é ascii...
9 6 - 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.
... ... @@ -12,21 +9,17 @@
12 9 - melhorar o botao de autorizar (desliga-se), usar antes um botao?
13 10 e.g. retornar None quando nao ha alteracoes relativamente à última vez.
14 11 ou usar push (websockets?)
15   -- pymips: nao pode executar syscalls do spim.
16   -- perguntas checkbox [right,wrong] com pelo menos uma opção correcta.
17   -- eventos unfocus?
  12 +- lidar com eventos unfocus.
18 13 - servidor nao esta a lidar com eventos scroll/resize. ignorar?
19 14 - Test.reset_answers() unused.
20 15  
21 16 # TODO
22 17  
  18 +- test: mostrar duração do teste com progressbar no navbar.
23 19 - submissao fazer um post ajax?
24   -- fazer package para instalar perguntations com pip.
25 20 - adicionar opcao para eliminar um teste em curso.
26   -- gerar teste qd o prof autoriza? melhor nao, pode apagar o teste em curso. gerar previamente e manter uma pool de testes gerados?
27 21 - enviar resposta de cada pergunta individualmente.
28 22 - experimentar gerador de svg que inclua no markdown da pergunta e ver se funciona.
29   -- 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.
30 23 - quando ha varias perguntas para escolher, escolher sucessivamente em vez de aleatoriamente.
31 24 - como refrescar a tabela de admin sem fazer reload da pagina?
32 25 - botao "testar resposta" que valida codigo relativamente a syntax, mas nao classifica. perguntas devem ter opcao validate: script.py. Aluno pressiona botao e codigo é enviado para servidor para validação, feedback é mostrado na pagina de teste.
... ... @@ -34,15 +27,12 @@ ou usar push (websockets?)
34 27 - test: Cada pergunta respondida é logo submetida.
35 28 - test: calculadora javascript.
36 29 - admin: histograma das notas.
37   -- admin: gerar os testes no momento em que são autorizados, e não no login. <- se prof autoriza aluno que já esta a realizar teste pode fazer reset e destruir teste???
38 30 - admin: mostrar as horas a que o teste terminou para os testes terminados.
39 31 - admin: histograma das notas.
40 32 - admin: mostrar teste gerado para aluno (tipo review).
41   -- test: mostrar duração do teste com progressbar no navbar.
42   -- fazer renderer para formulas com mathjax serverside (mathjax-node).
  33 +- fazer renderer para formulas com mathjax serverside (mathjax-node) ou usar katex.
43 34 - fazer renderer para imagens, com links /file?ref=xpto;name=zzz.jpg
44 35 - fazer renderer para linguagem assembly mips?
45   -- permitir eliminar teste a decorrer
46 36 - cancelar teste no menu admin. Dado o numero de aluno remove teste e faz logout do aluno.
47 37 - mathjax-node:
48 38 sudo pkg install node npm
... ... @@ -57,16 +47,18 @@ ou usar push (websockets?)
57 47 - se ocorrer um erro na correcçao avisar aluno para contactar o professor.
58 48 - abrir o teste numa janela maximizada e que nao permite que o aluno a redimensione/mova?
59 49 - detectar scroll e enviar posição para servidor (analise de scroll para detectar copianço? ou simplesmente para analisar como os alunos percorrem o teste)
60   -- single page web no teste/correcçao. Página construída em javascript, obter perguntas com ajax (para practice?).
61 50 - aviso na pagina principal para quem usa browser da treta
62 51 - criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput
63 52 - perguntas para professor corrigir mais tarde.
64 53 - fazer uma calculadora javascript e por no menu. surge como modal
65   -- GeoIP?
66   -- enviar logs para web?
67 54  
68 55 # FIXED
69 56  
  57 +- 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.
  58 +- fazer package para instalar perguntations com pip.
  59 +- pymips: nao pode executar syscalls do spim.
  60 +- exception sqlalchemy relacionada com threads.
  61 +- acrescentar logger.conf que sirva de base.
70 62 - questions.py textarea has a abspath which does not make sense! why is it there? not working for perguntations, but seems to work for aprendizations
71 63 - textarea foi modificado em aprendizations para receber cmd line args. corrigir aqui tb.
72 64 - usar npm para instalar javascript
... ...
README.md
... ... @@ -11,13 +11,13 @@
11 11  
12 12 ## Requirements
13 13  
14   -The webserver is a python application that requires `>=python3.6` and `pip` to be
15   -installed. `npm` (Node package management) is also necessary to install the
16   -javascript libraries.
  14 +The webserver is a python application that requires `>=python3.7` and `pip` to
  15 +be installed. Node package management `npm` is also necessary in order to
  16 +install the javascript libraries.
17 17  
18 18 ```bash
19   -sudo apt install python3 python3-pip python3-setuptools npm # Ubuntu
20   -sudo pkg install python36 py36-sqlite3 py36-pip py36-setuptools npm # FreeBSD
  19 +sudo apt install python3 python3-pip npm # Ubuntu
  20 +sudo pkg install python37 py37-sqlite3 py37-pip npm # FreeBSD
21 21 sudo port install python37 py37-pip py37-setuptools npm6 # MacOS
22 22 ```
23 23  
... ... @@ -38,19 +38,15 @@ This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it&#39;s in
38 38  
39 39 ## Installation
40 40  
41   -Download and install (`USERNAME` is your account on bitbucket):
  41 +Download and install:
42 42  
43 43 ```bash
44   -cd ~
45   -git clone https://USERNAME@bitbucket.org/USERNAME/perguntations.git
  44 +git clone https://git.xdi.uevora.pt/perguntations.git
46 45 cd perguntations
47 46 npm install
48 47 pip3 install .
49 48 ```
50 49  
51   -Here, the repository is installed in the user home directory.
52   -You may wish to adjust to somewhere else.
53   -
54 50 The command `npm` installs the javascript libraries and `pip3` installs the
55 51 python webserver.
56 52 This will also install any required dependencies.
... ... @@ -95,7 +91,7 @@ To run the demonstration test you need to initialize the database using one of
95 91 the following methods:
96 92  
97 93 ```bash
98   -cd ~/perguntations/demo
  94 +cd perguntations/demo
99 95  
100 96 initdb students.csv # initialize from a CSV file
101 97 initdb --admin # only adds the administrator account
... ... @@ -107,20 +103,20 @@ database.
107 103 The database stores user passwords and grades, but not the actual tests.
108 104  
109 105 A test is specified in a single `yaml` file.
110   -The demo already includes the `tutorial.yaml` that you can play with.
  106 +The demo already includes the `demo.yaml` that you can play with.
111 107  
112 108 The complete tests submitted by the students are stored in JSON files in the
113   -directory defined in `tutorial.yaml` under the option `answers_dir: ans`.
  109 +directory defined in `demo.yaml` under the option `answers_dir: ans`.
114 110 We also have to create this directory manually:
115 111  
116 112 ```bash
117 113 mkdir ans # directory where the tests will be saved
118 114 ```
119 115  
120   -Start the server and run the `tutorial.yaml` test:
  116 +Start the server and run the `demo.yaml` test:
121 117  
122 118 ```bash
123   -perguntations tutorial.yaml # run demo test
  119 +perguntations demo.yaml # run demo test
124 120 ```
125 121  
126 122 Several options are available, run `perguntations --help` for a list.
... ... @@ -205,7 +201,7 @@ sudo service pf start # enable pf firewall
205 201 ```
206 202  
207 203 Certificates are saved in `/usr/local/etc/letsencrypt/live/www.example.com/`.
208   -Copy them to the `certs` directory and change permissions:
  204 +Copy them to the `~/.local/share/certs` directory and change permissions:
209 205  
210 206 ```sh
211 207 chmod 400 cert.pem privkey.pem
... ... @@ -219,15 +215,14 @@ sudo certbot renew
219 215 sudo service pf start # start firewall
220 216 ```
221 217  
222   -Again, copy certificate files `privkey.pem` and `cert.pem` to the `certs`
223   -directory.
  218 +Again, copy certificate files `privkey.pem` and `cert.pem` to `~/.local/share/certs`.
224 219  
225 220 ---
226 221  
227 222 ## Troubleshooting
228 223  
229 224 - The server tries to run `python3` so this command must be accessible from
230   -user accounts. Currently, the minimum supported python version is 3.6.
  225 +user accounts. Currently, the minimum supported python version is 3.7.
231 226  
232 227 - If you are getting any `UnicodeEncodeError` type of errors that's because the
233 228 terminal is not supporting UTF-8.
... ...
config/logger-debug.yaml 0 → 100644
... ... @@ -0,0 +1,59 @@
  1 +---
  2 +version: 1
  3 +
  4 +formatters:
  5 + void:
  6 + format: ''
  7 + standard:
  8 + format: '%(asctime)s | %(levelname)-8s | %(module)-16s:%(lineno)4d | %(thread)d | %(message)s'
  9 +
  10 + error:
  11 + format: "\e[41m%(asctime)s | %(levelname)-8s | %(module)-16s:%(lineno)4d | %(thread)d | %(message)s\e[0m"
  12 +
  13 +handlers:
  14 + default:
  15 + level: 'DEBUG'
  16 + class: 'logging.StreamHandler'
  17 + formatter: 'standard'
  18 + stream: 'ext://sys.stdout'
  19 +
  20 + error:
  21 + level: 'ERROR'
  22 + class: 'logging.StreamHandler'
  23 + formatter: 'error'
  24 + stream: 'ext://sys.stdout'
  25 +
  26 +loggers:
  27 + '':
  28 + handlers: ['default', 'error']
  29 + level: 'DEBUG'
  30 +
  31 + 'perguntations.app':
  32 + handlers: ['default', 'error']
  33 + level: 'DEBUG'
  34 + propagate: false
  35 +
  36 + 'perguntations.models':
  37 + handlers: ['default', 'error']
  38 + level: 'DEBUG'
  39 + propagate: false
  40 +
  41 + 'perguntations.questions':
  42 + handlers: ['default', 'error']
  43 + level: 'DEBUG'
  44 + propagate: false
  45 +
  46 + 'perguntations.test':
  47 + handlers: ['default', 'error']
  48 + level: 'DEBUG'
  49 + propagate: false
  50 +
  51 + 'perguntations.tools':
  52 + handlers: ['default', 'error']
  53 + level: 'DEBUG'
  54 + propagate: false
  55 +
  56 + 'perguntations.parser_markdown':
  57 + handlers: ['default', 'error']
  58 + level: 'DEBUG'
  59 + propagate: false
... ...
config/logger.yaml 0 → 100644
... ... @@ -0,0 +1,51 @@
  1 +---
  2 +version: 1
  3 +
  4 +formatters:
  5 + void:
  6 + format: ''
  7 + standard:
  8 + format: '%(asctime)s | %(levelname)-8s | %(message)s'
  9 + datefmt: '%Y-%m-%d %H:%M:%S'
  10 +
  11 +handlers:
  12 + default:
  13 + level: 'INFO'
  14 + class: 'logging.StreamHandler'
  15 + formatter: 'standard'
  16 + stream: 'ext://sys.stdout'
  17 +
  18 +loggers:
  19 + '':
  20 + handlers: ['default']
  21 + level: 'INFO'
  22 +
  23 + 'perguntations.app':
  24 + handlers: ['default']
  25 + level: 'INFO'
  26 + propagate: false
  27 +
  28 + 'perguntations.models':
  29 + handlers: ['default']
  30 + level: 'INFO'
  31 + propagate: false
  32 +
  33 + 'perguntations.questions':
  34 + handlers: ['default']
  35 + level: 'INFO'
  36 + propagate: false
  37 +
  38 + 'perguntations.test':
  39 + handlers: ['default']
  40 + level: 'INFO'
  41 + propagate: false
  42 +
  43 + 'perguntations.tools':
  44 + handlers: ['default']
  45 + level: 'INFO'
  46 + propagate: false
  47 +
  48 + 'perguntations.parser_markdown':
  49 + handlers: ['default']
  50 + level: 'INFO'
  51 + propagate: false
... ...
perguntations/app.py
... ... @@ -45,7 +45,7 @@ async def hash_password(pw):
45 45 # Application
46 46 # ============================================================================
47 47 class App(object):
48   - # -----------------------------------------------------------------------
  48 + # ------------------------------------------------------------------------
49 49 # helper to manage db sessions using the `with` statement, for example
50 50 # with self.db_session() as s: s.query(...)
51 51 @contextmanager
... ... @@ -98,14 +98,14 @@ class App(object):
98 98 for student in self.get_all_students():
99 99 self.allow_student(student[0])
100 100  
101   - # -----------------------------------------------------------------------
  101 + # ------------------------------------------------------------------------
102 102 def exit(self):
103 103 if len(self.online) > 1:
104 104 online_students = ', '.join(self.online)
105 105 logger.warning(f'Students still online: {online_students}')
106 106 logger.critical('----------- !!! Server terminated !!! -----------')
107 107  
108   - # -----------------------------------------------------------------------
  108 + # ------------------------------------------------------------------------
109 109 async def login(self, uid, try_pw):
110 110 if uid not in self.allowed and uid != '0': # not allowed
111 111 logger.warning(f'Student {uid}: not allowed to login.')
... ... @@ -136,12 +136,12 @@ class App(object):
136 136 logger.info(f'Student {uid}: wrong password.')
137 137 return False
138 138  
139   - # -----------------------------------------------------------------------
  139 + # ------------------------------------------------------------------------
140 140 def logout(self, uid):
141 141 self.online.pop(uid, None) # remove from dict if exists
142 142 logger.info(f'Student {uid}: logged out.')
143 143  
144   - # -----------------------------------------------------------------------
  144 + # ------------------------------------------------------------------------
145 145 async def generate_test(self, uid):
146 146 if uid in self.online:
147 147 logger.info(f'Student {uid}: generating new test.')
... ... @@ -154,7 +154,7 @@ class App(object):
154 154 # this implies an error in the code. should never be here!
155 155 logger.critical(f'Student {uid}: offline, can\'t generate test')
156 156  
157   - # -----------------------------------------------------------------------
  157 + # ------------------------------------------------------------------------
158 158 # ans is a dictionary {question_index: answer, ...}
159 159 # for example: {0:'hello', 1:[1,2]}
160 160 async def correct_test(self, uid, ans):
... ... @@ -199,7 +199,7 @@ class App(object):
199 199 logger.info(f'Student {uid}: database updated.')
200 200 return grade
201 201  
202   - # -----------------------------------------------------------------------
  202 + # ------------------------------------------------------------------------
203 203 def giveup_test(self, uid):
204 204 t = self.online[uid]['test']
205 205 t.giveup()
... ... @@ -226,7 +226,7 @@ class App(object):
226 226 logger.info(f'Student {uid}: gave up.')
227 227 return t
228 228  
229   - # -----------------------------------------------------------------------
  229 + # ------------------------------------------------------------------------
230 230  
231 231 # --- helpers (getters)
232 232 # def get_student_name(self, uid):
... ...
perguntations/serve.py
... ... @@ -117,7 +117,6 @@ class LogoutHandler(BaseHandler):
117 117 # ----------------------------------------------------------------------------
118 118 # handles root / to redirect students to /test and admininistrator to /admin
119 119 # ----------------------------------------------------------------------------
120   -# TODO list available tests
121 120 class RootHandler(BaseHandler):
122 121 @tornado.web.authenticated
123 122 def get(self):
... ... @@ -168,9 +167,9 @@ class FileHandler(BaseHandler):
168 167 break # for loop
169 168  
170 169  
171   -# -------------------------------------------------------------------------
  170 +# ----------------------------------------------------------------------------
172 171 # Test shown to students
173   -# -------------------------------------------------------------------------
  172 +# ----------------------------------------------------------------------------
174 173 class TestHandler(BaseHandler):
175 174 _templates = {
176 175 'radio': 'question-radio.html',
... ... @@ -190,7 +189,7 @@ class TestHandler(BaseHandler):
190 189 @tornado.web.authenticated
191 190 async def get(self):
192 191 uid = self.current_user
193   - t = self.testapp.get_student_test(uid) # reload page returns same test
  192 + t = self.testapp.get_student_test(uid) # reloading returns same test
194 193 if t is None:
195 194 t = await self.testapp.generate_test(uid)
196 195 self.render('test.html', t=t, md=md_to_html, templ=self._templates)
... ... @@ -206,11 +205,11 @@ class TestHandler(BaseHandler):
206 205 t = self.testapp.get_student_test(uid)
207 206 ans = {}
208 207 for i, q in enumerate(t['questions']):
209   - qid = str(i) # question id
  208 + qid = str(i)
210 209 if 'answered-' + qid in self.request.arguments:
211 210 ans[i] = self.get_body_arguments(qid)
212 211  
213   - # remove list when it does not make sense...
  212 + # remove enclosing list in some question types
214 213 if q['type'] == 'radio':
215 214 if not ans[i]:
216 215 ans[i] = None
... ... @@ -220,17 +219,18 @@ class TestHandler(BaseHandler):
220 219 'numeric-interval'):
221 220 ans[i] = ans[i][0]
222 221  
  222 + # correct answered questions and logout
223 223 await self.testapp.correct_test(uid, ans)
224   -
225 224 self.testapp.logout(uid)
226 225 self.clear_cookie('user')
227 226  
  227 + # show final grade and grades of other tests in the database
228 228 allgrades = self.testapp.get_student_grades_from_all_tests(uid)
229 229 self.render('grade.html', t=t, allgrades=allgrades)
230 230  
231 231  
232   -# -------------------------------------------------------------------------
233   -# FIXME this should be a post in the test with command giveup instead of correct...
  232 +# ----------------------------------------------------------------------------
  233 +# FIXME should be a post in the test with command giveup instead of correct...
234 234 # class GiveupHandler(BaseHandler):
235 235 # @tornado.web.authenticated
236 236 # def get(self):
... ... @@ -242,7 +242,7 @@ class TestHandler(BaseHandler):
242 242 # self.render('grade.html', t=t, allgrades=self.testapp.get_student_grades_from_all_tests(uid))
243 243  
244 244  
245   -# --- REVIEW -------------------------------------------------------------
  245 +# --- REVIEW -----------------------------------------------------------------
246 246 class ReviewHandler(BaseHandler):
247 247 SUPPORTED_METHODS = ['GET']
248 248  
... ... @@ -330,7 +330,7 @@ class AdminHandler(BaseHandler):
330 330 logging.error(f'Unknown command: "{cmd}"')
331 331  
332 332  
333   -# -------------------------------------------------------------------------
  333 +# ----------------------------------------------------------------------------
334 334 def signal_handler(signal, frame):
335 335 r = input(' --> Stop webserver? (yes/no) ')
336 336 if r in ('yes', 'YES'):
... ... @@ -339,7 +339,7 @@ def signal_handler(signal, frame):
339 339 sys.exit(0)
340 340  
341 341  
342   -# -------------------------------------------------------------------------
  342 +# ----------------------------------------------------------------------------
343 343 def parse_cmdline_arguments():
344 344 parser = argparse.ArgumentParser(
345 345 description='Server for online tests. Enrolled students and tests '
... ... @@ -363,7 +363,7 @@ def parse_cmdline_arguments():
363 363 return parser.parse_args()
364 364  
365 365  
366   -# -------------------------------------------------------------------------
  366 +# ----------------------------------------------------------------------------
367 367 def get_logger_config(debug=False):
368 368 if debug:
369 369 filename = 'logger-debug.yaml'
... ... @@ -409,9 +409,9 @@ def get_logger_config(debug=False):
409 409 return load_yaml(config_file, default=default_config)
410 410  
411 411  
412   -# -------------------------------------------------------------------------
  412 +# ----------------------------------------------------------------------------
413 413 # Tornado web server
414   -# -------------------------------------------------------------------------
  414 +# ----------------------------------------------------------------------------
415 415 def main():
416 416 args = parse_cmdline_arguments()
417 417  
... ... @@ -477,6 +477,6 @@ def main():
477 477 raise
478 478  
479 479  
480   -# -------------------------------------------------------------------------
  480 +# ----------------------------------------------------------------------------
481 481 if __name__ == "__main__":
482 482 main()
... ...
perguntations/templates/test.html
... ... @@ -120,7 +120,8 @@
120 120 </div>
121 121 <div class="modal-body">
122 122 O teste será enviado para classificação e já não poderá voltar atrás.
123   - Antes de submeter, veja se respondeu a todas as questões e desactive as que não pretende classificar.
  123 + Antes de submeter, verifique se respondeu a todas as questões.
  124 + Desactive as perguntas que não pretende classificar para evitar eventuais penalizações.
124 125 </div>
125 126 <div class="modal-footer">
126 127 <button type="button" class="btn btn-danger btn-lg" data-dismiss="modal">Oops, NÃO!!!</button>
... ...
perguntations/test.py
... ... @@ -14,22 +14,22 @@ from perguntations.tools import load_yaml
14 14 logger = logging.getLogger(__name__)
15 15  
16 16  
17   -# ===========================================================================
  17 +# ============================================================================
18 18 class TestFactoryException(Exception):
19 19 pass
20 20  
21 21  
22   -# ===========================================================================
  22 +# ============================================================================
23 23 # Each instance of TestFactory() is a test generator.
24 24 # For example, if we want to serve two different tests, then we need two
25 25 # instances of TestFactory(), one for each test.
26   -# ===========================================================================
  26 +# ============================================================================
27 27 class TestFactory(dict):
28   - # -----------------------------------------------------------------------
  28 + # ------------------------------------------------------------------------
29 29 # Loads configuration from yaml file, then overrides some configurations
30 30 # using the conf argument.
31 31 # Base questions are added to a pool of questions factories.
32   - # -----------------------------------------------------------------------
  32 + # ------------------------------------------------------------------------
33 33 def __init__(self, conf):
34 34 # --- set test configutation and defaults
35 35 super().__init__({ # defaults
... ... @@ -88,16 +88,14 @@ class TestFactory(dict):
88 88  
89 89 # make factory only for the questions used in the test
90 90 if q['ref'] in qrefs:
  91 + q.setdefault('type', 'information')
91 92 q.update({
92 93 'filename': filename,
93 94 'path': dirname,
94 95 'index': i # position in the file, 0 based
95 96 })
96 97  
97   - q.setdefault('type', 'information')
98   -
99 98 self.question_factory[q['ref']] = QFactory(q)
100   - logger.debug(f'[TestFactory.__init__] QFactory: "{q["ref"]}".')
101 99  
102 100 # check if all the questions can be correctly generated
103 101 try:
... ... @@ -245,17 +243,17 @@ class TestFactory(dict):
245 243 # 'files': self['files'],
246 244 })
247 245  
248   - # -----------------------------------------------------------------------
  246 + # ------------------------------------------------------------------------
249 247 def __repr__(self):
250 248 testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items())
251 249 return '{\n' + testsettings + '\n}'
252 250  
253 251  
254   -# ===========================================================================
  252 +# ============================================================================
255 253 # Each instance Test() is a concrete test of a single student.
256   -# ===========================================================================
  254 +# ============================================================================
257 255 class Test(dict):
258   - # -----------------------------------------------------------------------
  256 + # ------------------------------------------------------------------------
259 257 def __init__(self, d):
260 258 super().__init__(d)
261 259 self['start_time'] = datetime.now()
... ... @@ -263,20 +261,20 @@ class Test(dict):
263 261 self['state'] = 'ACTIVE'
264 262 self['comment'] = ''
265 263  
266   - # -----------------------------------------------------------------------
  264 + # ------------------------------------------------------------------------
267 265 # Removes all answers from the test (clean)
268 266 def reset_answers(self):
269 267 for q in self['questions']:
270 268 q['answer'] = None
271 269  
272   - # -----------------------------------------------------------------------
  270 + # ------------------------------------------------------------------------
273 271 # Given a dictionary ans={'ref': 'some answer'} updates the
274 272 # answers of the test. Only affects questions referred.
275 273 def update_answers(self, ans):
276 274 for ref, answer in ans.items():
277 275 self['questions'][ref]['answer'] = answer
278 276  
279   - # -----------------------------------------------------------------------
  277 + # ------------------------------------------------------------------------
280 278 # Corrects all the answers of the test and computes the final grade
281 279 async def correct(self):
282 280 self['finish_time'] = datetime.now()
... ... @@ -290,7 +288,7 @@ class Test(dict):
290 288 self['grade'] = max(0, round(grade, 1)) # truncate negative grades
291 289 return self['grade']
292 290  
293   - # -----------------------------------------------------------------------
  291 + # ------------------------------------------------------------------------
294 292 def giveup(self):
295 293 self['finish_time'] = datetime.now()
296 294 self['state'] = 'QUIT'
... ...