Commit aec7789965f7720768e481b53bc8e6333d8d252c

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

- fix bug in password reset.

- new information question in the demo suggesting use of yamllint.
- satisfy pylint recommendations against logging f-strings
- handle JSON exception
- show file and ref both in the test and review
- add button to get detailed grades of all questions in a test
- version bump to 2020.05.dev3
BUGS.md
1 1  
2 2 # BUGS
3 3  
4   -- retornar None quando nao ha alteracoes relativamente à última vez.
5   -ou usar push (websockets?)
6   -- quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20
7 4 - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?)
8   -- na pagina grade.html as barras estao normalizadas para os limites scale_min e max do teste actual e nao do realizado.
  5 +
  6 +- em admin, quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20. a tabela teste na DB não tem a escala desse teste.
  7 +- em grade.html as barras estao normalizadas para os limites scale_min e max do teste actual e nao dos testes realizados no passado (tabela test devia guardar a escala).
9 8 - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>.
10   -- teste nao esta a mostrar imagens de vez em quando.
11 9 - 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 10 - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente?
13 11 - a revisao do teste não mostra as imagens.
14 12 - Test.reset_answers() unused.
15   -- incluir test_id na tabela questions (futuro semestre, pode quebrar compatibilidade).
16   -- na pagina admin, mostrar com cor vermelha as horas de entrada dos alunos que ja tenham excedido o tempo
  13 +- teste nao esta a mostrar imagens de vez em quando.???
17 14  
18 15 # TODO
19 16  
  17 +- na pagina admin, mostrar com cor vermelha as horas de entrada dos alunos que ja tenham excedido o tempo
  18 +- retornar None quando nao ha alteracoes relativamente à última vez.
  19 +ou usar push (websockets?)
20 20 - mudar ref do test para test_id (ref já é usado nas perguntas)
21 21 - servidor ntpd no x220 para configurar a data/hora dos portateis dell
22 22 - autorização dada, mas teste não disponível até que seja dada ordem para começar.
... ...
demo/demo.yaml
1 1 ---
2 2 # ============================================================================
3 3 # Unique identifier of the test.
  4 +# Valid names can only include letters, digits, dash and underscore,
  5 +# e.g. asc1-test3
4 6 # Database queries can be done in the terminal with
5   -# sqlite3 students.db "select * from tests where ref='tutorial'"
  7 +# sqlite3 students.db "select * from tests where ref='asc1-test3'"
6 8 ref: tutorial
7 9  
8 10 # Database file that includes student credentials, tests and questions grades.
... ... @@ -20,9 +22,9 @@ title: Teste de demonstração (tutorial)
20 22  
21 23 # Duration in minutes.
22 24 # (0 or undefined means infinite time)
23   -duration: 2
  25 +duration: 0
24 26  
25   -# Automatic test submission after the timeout 'duration'?
  27 +# Automatic test submission after the given 'duration' timeout
26 28 # (default: false)
27 29 autosubmit: true
28 30  
... ... @@ -72,7 +74,7 @@ questions:
72 74 - tut-warning
73 75 - [tut-alert1, tut-alert2]
74 76 - tut-generator
75   -
  77 + - tut-yamllint
76 78  
77 79 # test:
78 80 # - ref1
... ...
demo/questions/generators/generate-question.py
... ... @@ -18,11 +18,10 @@ print(f&quot;&quot;&quot;---
18 18 type: text
19 19 title: Geradores de perguntas
20 20 text: |
21   - Existe a possibilidade da pergunta ser gerada por um programa externo. Este
22   - programa deve escrever no `stdout` uma pergunta em formato `yaml` como nos
  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 23 exemplos anteriores. Pode também receber argumentos para parametrizar a
24   - geração da pergunta. Aqui está um exemplo de uma pergunta gerada por um
25   - script python:
  24 + pergunta. Aqui está um exemplo de uma pergunta gerada por um script python:
26 25  
27 26 ```python
28 27 #!/usr/bin/env python3
... ... @@ -46,9 +45,7 @@ text: |
46 45 A solução é {{r}}.''')
47 46 ```
48 47  
49   - Este script deve ter permissões para poder ser executado no terminal. Dá
50   - jeito usar o comando `gen-somar.py 1 100 | yamllint -` para validar o `yaml`
51   - gerado.
  48 + Este script deve ter permissões para poder ser executado no terminal.
52 49  
53 50 Para indicar que uma pergunta é gerada externamente, esta é declarada com
54 51  
... ... @@ -56,12 +53,12 @@ text: |
56 53 - type: generator
57 54 ref: gen-somar
58 55 script: gen-somar.py
59   - # opcional
  56 + # argumentos opcionais
60 57 args: [1, 100]
61 58 ```
62 59  
63   - Os argumentos `args` são opcionais e são passados para o programa como
64   - argumentos da linha de comando.
  60 + Opcionalmente, o programa pode receber uma lista de argumentos declarados em
  61 + `args`.
65 62  
66 63 ---
67 64  
... ...
demo/questions/questions-tutorial.yaml
... ... @@ -581,3 +581,25 @@
581 581 ref: tut-generator
582 582 script: generators/generate-question.py
583 583 args: [1, 100]
  584 +
  585 +# ----------------------------------------------------------------------------
  586 +- type: information
  587 + ref: tut-yamllint
  588 + title: Sugestões para validar yaml
  589 + text: |
  590 + Como os testes e perguntas são ficheiros `yaml`, é conveniente validar se
  591 + estão correctamente definitos. Um *linter* recomendado é o `yamllint`. Pode
  592 + ser instalado com `pip install yamllint` e usado do seguinte modo:
  593 +
  594 + ```sh
  595 + yamllint test.yaml
  596 + yamllint questions.yaml
  597 + ```
  598 +
  599 + No caso de programas geradores de perguntas e programas de correcção de
  600 + respostas pode usar-se um *pipe*:
  601 +
  602 + ```sh
  603 + generate-question | yamllint -
  604 + correct-answer | yamllint -
  605 + ```
... ...
perguntations/__init__.py
... ... @@ -32,7 +32,7 @@ proof of submission and for review.
32 32 '''
33 33  
34 34 APP_NAME = 'perguntations'
35   -APP_VERSION = '2020.05.dev2'
  35 +APP_VERSION = '2020.05.dev3'
36 36 APP_DESCRIPTION = __doc__
37 37  
38 38 __author__ = 'Miguel Barão'
... ...
perguntations/app.py
... ... @@ -276,11 +276,43 @@ class App():
276 276 uid, area, win_x, win_y, scr_x, scr_y)
277 277  
278 278 # ------------------------------------------------------------------------
  279 + # --- GETTERS
  280 + # ------------------------------------------------------------------------
279 281  
280   - # --- helpers (getters)
281 282 # def get_student_name(self, uid):
282 283 # return self.online[uid]['student']['name']
283 284  
  285 + def get_questions_csv(self):
  286 + '''generates a CSV with the grades of the test'''
  287 + test_id = self.testfactory['ref']
  288 +
  289 + with self.db_session() as sess:
  290 + grades = sess.query(Question.student_id, Question.starttime,
  291 + Question.ref, Question.grade)\
  292 + .filter(Question.test_id == test_id)\
  293 + .order_by(Question.student_id)\
  294 + .all()
  295 +
  296 + cols = ['Aluno', 'Início'] + \
  297 + [r for question in self.testfactory['questions']
  298 + for r in question['ref']]
  299 +
  300 + tests = {}
  301 + for q in grades:
  302 + student, qref, qgrade = q[:2], q[2], q[3]
  303 + tests.setdefault(student, {})[qref] = qgrade
  304 +
  305 + rows = [{'Aluno': test[0], 'Início': test[1], **q}
  306 + for test, q in tests.items()]
  307 +
  308 + csvstr = io.StringIO()
  309 + writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None,
  310 + delimiter=';', quoting=csv.QUOTE_ALL)
  311 + writer.writeheader()
  312 + writer.writerows(rows)
  313 + return test_id, csvstr.getvalue()
  314 +
  315 +
284 316 def get_test_csv(self):
285 317 '''generates a CSV with the grades of the test'''
286 318 with self.db_session() as sess:
... ... @@ -292,7 +324,7 @@ class App():
292 324  
293 325 csvstr = io.StringIO()
294 326 writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL)
295   - writer.writerow(('Número', 'Nota', 'Início', 'Fim'))
  327 + writer.writerow(('Aluno', 'Nota', 'Início', 'Fim'))
296 328 writer.writerows(grades)
297 329 return self.testfactory['ref'], csvstr.getvalue()
298 330  
... ... @@ -357,7 +389,10 @@ class App():
357 389 # if q['ref'] == ref and key in q['files']:
358 390 # return path.abspath(path.join(q['path'], q['files'][key]))
359 391  
360   - # --- helpers (change state)
  392 + # ------------------------------------------------------------------------
  393 + # --- SETTERS
  394 + # ------------------------------------------------------------------------
  395 +
361 396 def allow_student(self, uid):
362 397 '''allow a single student to login'''
363 398 self.allowed.add(uid)
... ...
perguntations/models.py
1 1 '''
2   -Database tables
  2 +SQLAlchemy ORM
  3 +
  4 +The classes below correspond to database tables
3 5 '''
4 6  
5 7  
... ... @@ -26,10 +28,10 @@ class Student(Base):
26 28 questions = relationship('Question', back_populates='student')
27 29  
28 30 def __repr__(self):
29   - return f'Student:\n\
30   - id: "{self.id}"\n\
31   - name: "{self.name}"\n\
32   - password: "{self.password}"'
  31 + return (f'Student:\n'
  32 + f' id: "{self.id}"\n'
  33 + f' name: "{self.name}"\n'
  34 + f' password: "{self.password}"\n')
33 35  
34 36  
35 37 # ----------------------------------------------------------------------------
... ... @@ -38,7 +40,7 @@ class Test(Base):
38 40 __tablename__ = 'tests'
39 41 id = Column(Integer, primary_key=True) # auto_increment
40 42 ref = Column(String)
41   - title = Column(String) # FIXME depends on ref and should come from another table...
  43 + title = Column(String)
42 44 grade = Column(Float)
43 45 state = Column(String) # ACTIVE, FINISHED, QUIT, NULL
44 46 comment = Column(String)
... ... @@ -52,17 +54,17 @@ class Test(Base):
52 54 questions = relationship('Question', back_populates='test')
53 55  
54 56 def __repr__(self):
55   - return f'Test:\n\
56   - id: "{self.id}"\n\
57   - ref: "{self.ref}"\n\
58   - title: "{self.title}"\n\
59   - grade: "{self.grade}"\n\
60   - state: "{self.state}"\n\
61   - comment: "{self.comment}"\n\
62   - starttime: "{self.starttime}"\n\
63   - finishtime: "{self.finishtime}"\n\
64   - filename: "{self.filename}"\n\
65   - student_id: "{self.student_id}"\n'
  57 + return (f'Test:\n'
  58 + f' id: "{self.id}"\n'
  59 + f' ref: "{self.ref}"\n'
  60 + f' title: "{self.title}"\n'
  61 + f' grade: "{self.grade}"\n'
  62 + f' state: "{self.state}"\n'
  63 + f' comment: "{self.comment}"\n'
  64 + f' starttime: "{self.starttime}"\n'
  65 + f' finishtime: "{self.finishtime}"\n'
  66 + f' filename: "{self.filename}"\n'
  67 + f' student_id: "{self.student_id}"\n')
66 68  
67 69  
68 70 # ---------------------------------------------------------------------------
... ... @@ -82,11 +84,11 @@ class Question(Base):
82 84 test = relationship('Test', back_populates='questions')
83 85  
84 86 def __repr__(self):
85   - return f'Question:\n\
86   - id: "{self.id}"\n\
87   - ref: "{self.ref}"\n\
88   - grade: "{self.grade}"\n\
89   - starttime: "{self.starttime}"\n\
90   - finishtime: "{self.finishtime}"\n\
91   - student_id: "{self.student_id}"\n\
92   - test_id: "{self.test_id}"\n'
  87 + return (f'Question:\n'
  88 + f' id: "{self.id}"\n'
  89 + f' ref: "{self.ref}"\n'
  90 + f' grade: "{self.grade}"\n'
  91 + f' starttime: "{self.starttime}"\n'
  92 + f' finishtime: "{self.finishtime}"\n'
  93 + f' student_id: "{self.student_id}"\n' # FIXME normal form
  94 + f' test_id: "{self.test_id}"\n')
... ...
perguntations/serve.py
... ... @@ -63,7 +63,8 @@ class WebApplication(tornado.web.Application):
63 63 # ----------------------------------------------------------------------------
64 64 def admin_only(func):
65 65 '''
66   - Decorator used to restrict access to the administrator. For example:
  66 + Decorator used to restrict access to the administrator.
  67 + Example:
67 68  
68 69 @admin_only()
69 70 def get(self): ...
... ... @@ -91,7 +92,7 @@ class BaseHandler(tornado.web.RequestHandler):
91 92  
92 93 def get_current_user(self):
93 94 '''
94   - HTML is stateless, so a cookie is used to identify the user.
  95 + Since HTTP is stateless, a cookie is used to identify the user.
95 96 This function returns the cookie for the current user.
96 97 '''
97 98 cookie = self.get_secure_cookie('user')
... ... @@ -191,6 +192,15 @@ class AdminWebservice(BaseHandler):
191 192 self.write(data)
192 193 await self.flush()
193 194  
  195 + if cmd == 'questionscsv':
  196 + test_ref, data = self.testapp.get_questions_csv()
  197 + self.set_header('Content-Type', 'text/csv')
  198 + self.set_header('content-Disposition',
  199 + f'attachment; filename={test_ref}-detailed.csv')
  200 + self.write(data)
  201 + await self.flush()
  202 +
  203 +
194 204 # ----------------------------------------------------------------------------
195 205 class AdminHandler(BaseHandler):
196 206 '''Handle /admin'''
... ... @@ -236,7 +246,7 @@ class AdminHandler(BaseHandler):
236 246 self.testapp.deny_student(value)
237 247  
238 248 elif cmd == 'reset_password':
239   - await self.testapp.update_student_password(uid=value, pw='')
  249 + await self.testapp.update_student_password(uid=value, password='')
240 250  
241 251 elif cmd == 'insert_student':
242 252 student = json.loads(value)
... ... @@ -244,7 +254,7 @@ class AdminHandler(BaseHandler):
244 254 name=student['name'])
245 255  
246 256 else:
247   - logging.error(f'Unknown command: "{cmd}"')
  257 + logging.error('Unknown command: "%s"', cmd)
248 258  
249 259  
250 260 # ----------------------------------------------------------------------------
... ... @@ -337,11 +347,11 @@ class FileHandler(BaseHandler):
337 347 try:
338 348 file = open(filepath, 'rb')
339 349 except FileNotFoundError:
340   - logging.error(f'File not found: {filepath}')
  350 + logging.error('File not found: %s', filepath)
341 351 except PermissionError:
342   - logging.error(f'No permission: {filepath}')
  352 + logging.error('No permission: %s', filepath)
343 353 except OSError:
344   - logging.error(f'Error opening file: {filepath}')
  354 + logging.error('Error opening file: %s', filepath)
345 355 else:
346 356 data = file.read()
347 357 file.close()
... ... @@ -466,21 +476,25 @@ class ReviewHandler(BaseHandler):
466 476 Opens JSON file with a given corrected test and renders it
467 477 '''
468 478 test_id = self.get_query_argument('test_id', None)
469   - logging.info(f'Review test {test_id}.')
  479 + logging.info('Review test %s.', test_id)
470 480 fname = self.testapp.get_json_filename_of_test(test_id)
471 481  
472 482 if fname is None:
473 483 raise tornado.web.HTTPError(404) # Not Found
474 484  
475 485 try:
476   - jsonfile = open(path.expanduser(fname))
477   - except OSError:
478   - logging.error(f'Cannot open "{fname}" for review.')
479   - else:
480   - with jsonfile:
  486 + with open(path.expanduser(fname)) as jsonfile:
481 487 test = json.load(jsonfile)
482   - self.render('review.html', t=test, md=md_to_html,
483   - templ=self._templates)
  488 + except OSError:
  489 + logging.error('Cannot open "%s" for review.', fname)
  490 + raise tornado.web.HTTPError(404) # Not Found
  491 + except json.JSONDecodeError as exc:
  492 + logging.error('JSON error in "%s": %s', fname, exc)
  493 + raise tornado.web.HTTPError(404) # Not Found
  494 +
  495 + print(test['show_ref'])
  496 + self.render('review.html', t=test, md=md_to_html,
  497 + templ=self._templates)
484 498  
485 499  
486 500 # ----------------------------------------------------------------------------
... ... @@ -517,10 +531,10 @@ def run_webserver(app, ssl_opt, port, debug):
517 531 try:
518 532 httpserver.listen(port)
519 533 except OSError:
520   - logging.critical(f'Cannot bind port {port}. Already in use?')
  534 + logging.critical('Cannot bind port %d. Already in use?', port)
521 535 sys.exit(1)
522 536  
523   - logging.info(f'Webserver listening on {port}... (Ctrl-C to stop)')
  537 + logging.info('Webserver listening on %d... (Ctrl-C to stop)', port)
524 538 signal.signal(signal.SIGINT, signal_handler)
525 539  
526 540 # --- run webserver
... ...
perguntations/templates/admin.html
... ... @@ -76,7 +76,8 @@
76 76 Base de dados: <code id="database">--</code><br>
77 77 </p>
78 78 <p>
79   - <a href="/adminwebservice?cmd=testcsv" class="btn btn-primary">Obter CSV com as notas</a>
  79 + <a href="/adminwebservice?cmd=testcsv" class="btn btn-primary">Obter CSV das notas</a>
  80 + <a href="/adminwebservice?cmd=questionscsv" class="btn btn-primary">Obter CSV detalhado</a>
80 81 </p>
81 82 </div> <!-- jumbotron -->
82 83  
... ...
perguntations/templates/question-information.html
... ... @@ -19,8 +19,7 @@
19 19  
20 20 {% if show_ref %}
21 21 <hr>
22   - path: <code>{{ q['path'] }}</code><br>
23   - file: <code>{{ q['filename'] }}</code><br>
  22 + file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br>
24 23 ref: <code>{{ q['ref'] }}</code>
25 24 {% end %}
26 25 </div>
27 26 \ No newline at end of file
... ...
perguntations/templates/question.html
... ... @@ -31,8 +31,7 @@
31 31  
32 32 {% if show_ref %}
33 33 <div class="card-footer">
34   - path: <code>{{ q['path'] }}</code><br>
35   - file: <code>{{ q['filename'] }}</code><br>
  34 + file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br>
36 35 ref: <code>{{ q['ref'] }}</code>
37 36 </div>
38 37 {% end %}
... ...
perguntations/templates/review-question-information.html
... ... @@ -16,4 +16,9 @@
16 16 <div id="text">
17 17 {{ md(q['text']) }}
18 18 </div>
  19 + {% if t['show_ref'] %}
  20 + <hr>
  21 + file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br>
  22 + ref: <code>{{ q['ref'] }}</code>
  23 + {% end %}
19 24 </div>
20 25 \ No newline at end of file
... ...
perguntations/templates/review-question.html
... ... @@ -65,7 +65,9 @@
65 65 {% end %}
66 66  
67 67 {% if t['show_ref'] %}
68   - <code>{{q['ref']}}</code>
  68 + <hr>
  69 + file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br>
  70 + ref: <code>{{ q['ref'] }}</code>
69 71 {% end %}
70 72  
71 73 </div> <!-- card-footer -->
... ... @@ -107,7 +109,9 @@
107 109 </p>
108 110  
109 111 {% if t['show_ref'] %}
110   - <code>{{ q['ref'] }}</code>
  112 + <hr>
  113 + file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br>
  114 + ref: <code>{{ q['ref'] }}</code>
111 115 {% end %}
112 116  
113 117 </div> <!-- card-footer -->
... ...