Commit c37266817994065159840b680208d2f3493af2c7

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

- adds button to /admin page to download CSV file with grades.

- button to close the test after the grades.
- replace (scale_points, scale_min, scale_max) by "scale: [0, 20]"
- updates demo.yaml
- correct-question.py changed to count the number of correct colors
- catch exception during loading test configuration
- fix exception handling of sqlalchemy
- change default logging to include seconds
- update BUGS.md
@@ -2,8 +2,6 @@ @@ -2,8 +2,6 @@
2 # BUGS 2 # BUGS
3 3
4 - quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20 4 - quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20
5 -- servidor ntpd para configurar a data/hora dos portateis dell  
6 -- link na pagina com a nota para voltar ao principio.  
7 - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) 5 - 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. 6 - na pagina grade.html as barras estao normalizadas para os limites scale_min e max do teste actual e nao do realizado.
9 - 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>. 7 - 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>.
@@ -22,9 +20,11 @@ ou usar push (websockets?) @@ -22,9 +20,11 @@ ou usar push (websockets?)
22 20
23 # TODO 21 # TODO
24 22
  23 +- servidor ntpd para configurar a data/hora dos portateis dell
  24 +- autorização dada, mas teste não disponível até que seja dada ordem para começar.
  25 +- alunos com necessidades especiais nao podem ter autosubmit. ter um autosubmit_exceptions: ['123', '456']
25 - mostrar unfocus e window area em /admin 26 - mostrar unfocus e window area em /admin
26 - testar as perguntas todas no início do teste. 27 - testar as perguntas todas no início do teste.
27 -- test: mostrar duração do teste com progressbar no navbar.  
28 - submissao fazer um post ajax? 28 - submissao fazer um post ajax?
29 - adicionar opcao para eliminar um teste em curso. 29 - adicionar opcao para eliminar um teste em curso.
30 - enviar resposta de cada pergunta individualmente. 30 - enviar resposta de cada pergunta individualmente.
@@ -63,6 +63,9 @@ ou usar push (websockets?) @@ -63,6 +63,9 @@ ou usar push (websockets?)
63 63
64 # FIXED 64 # FIXED
65 65
  66 +- link na pagina com a nota para voltar ao principio.
  67 +- default logger config mostrar horas com segundos
  68 +- test: mostrar duração do teste com progressbar no navbar.
66 - lidar com eventos unfocus. 69 - lidar com eventos unfocus.
67 - servidor nao esta a lidar com eventos resize. 70 - servidor nao esta a lidar com eventos resize.
68 - sock.bind(sockaddr) OSError: [Errno 48] Address already in use 71 - sock.bind(sockaddr) OSError: [Errno 48] Address already in use
demo/demo.yaml
1 --- 1 ---
2 # ============================================================================ 2 # ============================================================================
3 -# The test reference should be a unique identifier. It is saved in the database  
4 -# so that queries can be done in the terminal like  
5 -# sqlite3 students.db "select * from tests where ref='demo'" 3 +# Unique identifier of the test.
  4 +# Database queries can be done in the terminal with
  5 +# sqlite3 students.db "select * from tests where ref='tutorial'"
6 ref: tutorial 6 ref: tutorial
7 7
8 -# Database with student credentials and grades of all questions and tests done  
9 -# The database is an sqlite3 file generate with the command initdb 8 +# Database file that includes student credentials, tests and questions grades.
  9 +# It's a sqlite3 database generated with the command 'initdb'
10 database: students.db 10 database: students.db
11 11
12 -# Directory where the tests including submitted answers and grades are stored.  
13 -# The submitted tests and their corrections can be reviewed later. 12 +# Directory where the submitted and corrected test are stored for later review.
14 answers_dir: ans 13 answers_dir: ans
15 14
16 # --- optional settings: ----------------------------------------------------- 15 # --- optional settings: -----------------------------------------------------
17 16
18 -# You may wish to refer the course, year or kind of test 17 +# Title of this test, e.g. course name, year or test number
19 # (default: '') 18 # (default: '')
20 title: Teste de demonstração (tutorial) 19 title: Teste de demonstração (tutorial)
21 20
22 # Duration in minutes. 21 # Duration in minutes.
23 # (0 or undefined means infinite time) 22 # (0 or undefined means infinite time)
24 duration: 2 23 duration: 2
25 -autosubmit: true  
26 24
27 -# Show points for each question, scale 0-20. 25 +# Automatic test submission after the timeout 'duration'?
28 # (default: false) 26 # (default: false)
  27 +autosubmit: true
  28 +
  29 +# Show points for each question (min and max).
  30 +# (default: true)
29 show_points: true 31 show_points: true
30 32
31 -# scale final grade to the interval [scale_min, scale_max]  
32 -# (default: scale to [0,20])  
33 -scale_max: 20  
34 -scale_min: 0  
35 -scale_points: true 33 +# scale final grade to an interval, e.g. [0, 20], keeping the relative weight
  34 +# of the points declared in the questions below.
  35 +# (default: no scaling, just use question points)
  36 +scale: [0, 5]
  37 +
  38 +# DEPRECATED: old version, to be removed
  39 +# scale_max: 20
  40 +# scale_min: 0
  41 +# scale_points: true
36 42
37 # ---------------------------------------------------------------------------- 43 # ----------------------------------------------------------------------------
38 # Base path applied to the questions files and all the scripts 44 # Base path applied to the questions files and all the scripts
@@ -50,7 +56,7 @@ files: @@ -50,7 +56,7 @@ files:
50 # The order is preserved. 56 # The order is preserved.
51 # There are several ways to define each question (explained below). 57 # There are several ways to define each question (explained below).
52 questions: 58 questions:
53 - - tut-test 59 + - ref: tut-test
54 - tut-questions 60 - tut-questions
55 61
56 - tut-radio 62 - tut-radio
demo/questions/correct/correct-question.py
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
  3 +'''
  4 +Demonstação de um script de correcção
  5 +'''
  6 +
3 import re 7 import re
4 import sys 8 import sys
5 9
6 -msg1 = '''---  
7 -grade: 1.0  
8 -comments: Muito bem!  
9 -''' 10 +s = sys.stdin.read()
10 11
11 -msg0 = '''  
12 -grade: 0.0  
13 -comments: A resposta correcta é "red green blue".  
14 -''' 12 +ans = set(re.findall(r'[\w]+', s.lower())) # get words in lowercase
  13 +rgb = set(['red', 'green', 'blue']) # the correct answer
15 14
16 -s = sys.stdin.read() 15 +# a nota é o número de cores certas menos o número de erradas
  16 +grade = max(0,
  17 + len(rgb.intersection(ans)) - len(ans.difference(rgb))) / 3
17 18
18 -answer = set(re.findall(r'[\w]+', s.lower())) # get words in lowercase  
19 -rgb_colors = set(['red', 'green', 'blue']) # the correct answer 19 +if ans == rgb:
  20 + print('---\n'
  21 + 'grade: 1.0\n'
  22 + 'comments: Muito bem!')
20 23
21 -if answer == rgb_colors:  
22 - print(msg1)  
23 else: 24 else:
24 - print(msg0) 25 + print('---\n'
  26 + f'grade: {grade}\n'
  27 + 'comments: A resposta correcta é "red green blue".')
demo/questions/generators/generate-question.py
@@ -18,10 +18,11 @@ print(f&quot;&quot;&quot;--- @@ -18,10 +18,11 @@ print(f&quot;&quot;&quot;---
18 type: text 18 type: text
19 title: Geradores de perguntas 19 title: Geradores de perguntas
20 text: | 20 text: |
21 - Existe a possibilidade da pergunta ser gerada por um programa externo.  
22 - Este programa deve escrever no `stdout` uma pergunta em formato `yaml` como  
23 - os anteriores. Pode também receber argumentos para parametrizar a geração da  
24 - pergunta. Aqui está um exemplo de uma pergunta gerada por um script python: 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
  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:
25 26
26 ```python 27 ```python
27 #!/usr/bin/env python3 28 #!/usr/bin/env python3
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.04.dev5' 35 +APP_VERSION = '2020.05.dev1'
36 APP_DESCRIPTION = __doc__ 36 APP_DESCRIPTION = __doc__
37 37
38 __author__ = 'Miguel Barão' 38 __author__ = 'Miguel Barão'
perguntations/app.py
@@ -6,13 +6,15 @@ Main application module @@ -6,13 +6,15 @@ Main application module
6 # python standard libraries 6 # python standard libraries
7 import asyncio 7 import asyncio
8 from contextlib import contextmanager # `with` statement in db sessions 8 from contextlib import contextmanager # `with` statement in db sessions
  9 +import csv
  10 +import io
9 import json 11 import json
10 import logging 12 import logging
11 from os import path 13 from os import path
12 14
13 -# user installed packages 15 +# installed packages
14 import bcrypt 16 import bcrypt
15 -from sqlalchemy import create_engine 17 +from sqlalchemy import create_engine, exc
16 from sqlalchemy.orm import sessionmaker 18 from sqlalchemy.orm import sessionmaker
17 19
18 # this project 20 # this project
@@ -73,9 +75,10 @@ class App(): @@ -73,9 +75,10 @@ class App():
73 try: 75 try:
74 yield session 76 yield session
75 session.commit() 77 session.commit()
76 - except Exception: 78 + except exc.SQLAlchemyError:
77 logger.error('DB rollback!!!') 79 logger.error('DB rollback!!!')
78 session.rollback() 80 session.rollback()
  81 + raise
79 finally: 82 finally:
80 session.close() 83 session.close()
81 84
@@ -85,7 +88,12 @@ class App(): @@ -85,7 +88,12 @@ class App():
85 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere 88 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere
86 89
87 logger.info('Loading test configuration "%s".', conf["testfile"]) 90 logger.info('Loading test configuration "%s".', conf["testfile"])
88 - testconf = load_yaml(conf['testfile']) 91 + try:
  92 + testconf = load_yaml(conf['testfile'])
  93 + except Exception as exc:
  94 + logger.critical('Error loading test configuration YAML.')
  95 + raise AppException(exc)
  96 +
89 testconf.update(conf) # command line options override configuration 97 testconf.update(conf) # command line options override configuration
90 98
91 # start test factory 99 # start test factory
@@ -258,7 +266,7 @@ class App(): @@ -258,7 +266,7 @@ class App():
258 266
259 # ------------------------------------------------------------------------ 267 # ------------------------------------------------------------------------
260 def event_test(self, uid, cmd, value): 268 def event_test(self, uid, cmd, value):
261 - '''handle browser events the occur during the test''' 269 + '''handles browser events the occur during the test'''
262 if cmd == 'focus': 270 if cmd == 'focus':
263 logger.info('Student %s: focus %s', uid, value) 271 logger.info('Student %s: focus %s', uid, value)
264 elif cmd == 'size': 272 elif cmd == 'size':
@@ -273,6 +281,21 @@ class App(): @@ -273,6 +281,21 @@ class App():
273 # def get_student_name(self, uid): 281 # def get_student_name(self, uid):
274 # return self.online[uid]['student']['name'] 282 # return self.online[uid]['student']['name']
275 283
  284 + def get_test_csv(self):
  285 + '''generates a CSV with the grades of the test'''
  286 + with self.db_session() as sess:
  287 + grades = sess.query(Test.student_id, Test.grade,
  288 + Test.starttime, Test.finishtime)\
  289 + .filter(Test.ref == self.testfactory['ref'])\
  290 + .order_by(Test.student_id)\
  291 + .all()
  292 +
  293 + csvstr = io.StringIO()
  294 + writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL)
  295 + writer.writerow(('Número', 'Nota', 'Início', 'Fim'))
  296 + writer.writerows(grades)
  297 + return csvstr.getvalue()
  298 +
276 def get_student_test(self, uid, default=None): 299 def get_student_test(self, uid, default=None):
277 '''get test from online student''' 300 '''get test from online student'''
278 return self.online[uid].get('test', default) 301 return self.online[uid].get('test', default)
@@ -320,11 +343,7 @@ class App(): @@ -320,11 +343,7 @@ class App():
320 .get('start_time', ''), 343 .get('start_time', ''),
321 'password_defined': pw != '', 344 'password_defined': pw != '',
322 'grades': self.get_student_grades_from_test( 345 'grades': self.get_student_grades_from_test(
323 - uid,  
324 - self.testfactory['ref']  
325 - ),  
326 -  
327 - # 'focus': self.online.get(uid, {}).get('student', {}).get('focus', True), # FIXME 346 + uid, self.testfactory['ref'])
328 } for uid, name, pw in self.get_all_students()] 347 } for uid, name, pw in self.get_all_students()]
329 348
330 # def get_allowed_students(self): 349 # def get_allowed_students(self):
@@ -363,7 +382,7 @@ class App(): @@ -363,7 +382,7 @@ class App():
363 try: 382 try:
364 with self.db_session() as sess: 383 with self.db_session() as sess:
365 sess.add(Student(id=uid, name=name, password='')) 384 sess.add(Student(id=uid, name=name, password=''))
366 - except Exception:  
367 - logger.error('Insert failed: student %s already exists.', uid) 385 + except exc.SQLAlchemyError:
  386 + logger.error('Insert failed: student %s already exists?', uid)
368 else: 387 else:
369 logger.info('New student inserted: %s, %s', uid, name) 388 logger.info('New student inserted: %s, %s', uid, name)
perguntations/main.py
@@ -74,7 +74,7 @@ def get_logger_config(debug=False): @@ -74,7 +74,7 @@ def get_logger_config(debug=False):
74 'formatters': { 74 'formatters': {
75 'standard': { 75 'standard': {
76 'format': '%(asctime)s %(levelname)-8s %(message)s', 76 'format': '%(asctime)s %(levelname)-8s %(message)s',
77 - 'datefmt': '%H:%M', 77 + 'datefmt': '%H:%M:%S',
78 }, 78 },
79 }, 79 },
80 'handlers': { 80 'handlers': {
perguntations/serve.py
@@ -42,6 +42,7 @@ class WebApplication(tornado.web.Application): @@ -42,6 +42,7 @@ class WebApplication(tornado.web.Application):
42 (r'/file', FileHandler), 42 (r'/file', FileHandler),
43 # (r'/root', MainHandler), # FIXME 43 # (r'/root', MainHandler), # FIXME
44 # (r'/ws', AdminSocketHandler), 44 # (r'/ws', AdminSocketHandler),
  45 + (r'/adminwebservice', AdminWebservice),
45 (r'/studentwebservice', StudentWebservice), 46 (r'/studentwebservice', StudentWebservice),
46 (r'/', RootHandler), 47 (r'/', RootHandler),
47 ] 48 ]
@@ -62,7 +63,10 @@ class WebApplication(tornado.web.Application): @@ -62,7 +63,10 @@ class WebApplication(tornado.web.Application):
62 # ---------------------------------------------------------------------------- 63 # ----------------------------------------------------------------------------
63 def admin_only(func): 64 def admin_only(func):
64 ''' 65 '''
65 - Decorator used to restrict access to the administrator 66 + Decorator used to restrict access to the administrator. For example:
  67 +
  68 + @admin_only()
  69 + def get(self): ...
66 ''' 70 '''
67 @functools.wraps(func) 71 @functools.wraps(func)
68 async def wrapper(self, *args, **kwargs): 72 async def wrapper(self, *args, **kwargs):
@@ -75,14 +79,14 @@ def admin_only(func): @@ -75,14 +79,14 @@ def admin_only(func):
75 # ---------------------------------------------------------------------------- 79 # ----------------------------------------------------------------------------
76 class BaseHandler(tornado.web.RequestHandler): 80 class BaseHandler(tornado.web.RequestHandler):
77 ''' 81 '''
78 - Base handler. Other handlers will inherit this one. 82 + Handlers should inherit this one instead of tornado.web.RequestHandler.
  83 + It automatically gets the user cookie, which is required to identify the
  84 + user in most handlers.
79 ''' 85 '''
80 86
81 @property 87 @property
82 def testapp(self): 88 def testapp(self):
83 - '''  
84 - simplifies access to the application  
85 - ''' 89 + '''simplifies access to the application'''
86 return self.application.testapp 90 return self.application.testapp
87 91
88 def get_current_user(self): 92 def get_current_user(self):
@@ -155,8 +159,8 @@ class BaseHandler(tornado.web.RequestHandler): @@ -155,8 +159,8 @@ class BaseHandler(tornado.web.RequestHandler):
155 159
156 class StudentWebservice(BaseHandler): 160 class StudentWebservice(BaseHandler):
157 ''' 161 '''
158 - Receive ajax from students in the test:  
159 - focus, unfocus 162 + Receive ajax from students in the test in response from focus, unfocus and
  163 + resize events.
160 ''' 164 '''
161 165
162 @tornado.web.authenticated 166 @tornado.web.authenticated
@@ -167,6 +171,25 @@ class StudentWebservice(BaseHandler): @@ -167,6 +171,25 @@ class StudentWebservice(BaseHandler):
167 value = json.loads(self.get_body_argument('value', None)) 171 value = json.loads(self.get_body_argument('value', None))
168 self.testapp.event_test(uid, cmd, value) 172 self.testapp.event_test(uid, cmd, value)
169 173
  174 +
  175 +# ----------------------------------------------------------------------------
  176 +class AdminWebservice(BaseHandler):
  177 + '''
  178 + Receive ajax requests from admin
  179 + '''
  180 +
  181 + @tornado.web.authenticated
  182 + @admin_only
  183 + async def get(self):
  184 + '''admin webservices that do not change state'''
  185 + cmd = self.get_query_argument('cmd')
  186 + if cmd == 'testcsv':
  187 + self.set_header('Content-Type', 'text/csv')
  188 + self.set_header('content-Disposition',
  189 + 'attachment; filename=notas.csv')
  190 + self.write(self.testapp.get_test_csv())
  191 + await self.flush()
  192 +
170 # ---------------------------------------------------------------------------- 193 # ----------------------------------------------------------------------------
171 class AdminHandler(BaseHandler): 194 class AdminHandler(BaseHandler):
172 '''Handle /admin''' 195 '''Handle /admin'''
perguntations/static/js/admin.js
@@ -14,35 +14,27 @@ jQuery.postJSON = function(url, args) { @@ -14,35 +14,27 @@ jQuery.postJSON = function(url, args) {
14 $(document).ready(function() { 14 $(document).ready(function() {
15 function button_handlers() { 15 function button_handlers() {
16 // button handlers (runs once) 16 // button handlers (runs once)
17 - $("#allow_all").click(  
18 - function() {  
19 - $(":checkbox").prop("checked", true).trigger('change');  
20 - }  
21 - );  
22 - $("#deny_all").click(  
23 - function() {  
24 - $(":checkbox").prop("checked", false).trigger('change');  
25 - }  
26 - );  
27 - $("#reset_password").click(  
28 - function () {  
29 - $.postJSON("/admin", {  
30 - "cmd": "reset_password",  
31 - "value": $("#reset_number").val()  
32 - });  
33 - }  
34 - );  
35 - $("#inserir_novo_aluno").click(  
36 - function () {  
37 - $.postJSON("/admin", {  
38 - "cmd": "insert_student",  
39 - "value": JSON.stringify({  
40 - "number": $("#novo_numero").val(),  
41 - "name": $("#novo_nome").val()  
42 - })  
43 - });  
44 - }  
45 - ); 17 + $("#allow_all").click(function() {
  18 + $(":checkbox").prop("checked", true).trigger('change');
  19 + });
  20 + $("#deny_all").click(function() {
  21 + $(":checkbox").prop("checked", false).trigger('change');
  22 + });
  23 + $("#reset_password").click(function () {
  24 + $.postJSON("/admin", {
  25 + "cmd": "reset_password",
  26 + "value": $("#reset_number").val()
  27 + });
  28 + });
  29 + $("#inserir_novo_aluno").click(function () {
  30 + $.postJSON("/admin", {
  31 + "cmd": "insert_student",
  32 + "value": JSON.stringify({
  33 + "number": $("#novo_numero").val(),
  34 + "name": $("#novo_nome").val()
  35 + })
  36 + });
  37 + });
46 // authorization checkboxes in the students_table: 38 // authorization checkboxes in the students_table:
47 $("tbody", "#students_table").on("change", "input", autorizeStudent); 39 $("tbody", "#students_table").on("change", "input", autorizeStudent);
48 } 40 }
perguntations/templates/admin.html
@@ -68,24 +68,29 @@ @@ -68,24 +68,29 @@
68 <div class="container-fluid"> 68 <div class="container-fluid">
69 69
70 <div class="jumbotron"> 70 <div class="jumbotron">
71 - <h3 id="title"></h3>  
72 - Ref: <span id="ref"></span><br>  
73 - Enunciado: <span id="filename"></span><br>  
74 - Base de dados: <span id="database"></span><br>  
75 - Testes submetidos: <span id="answers_dir"></span> 71 + <h3 id="title"></h3>
  72 + <p>
  73 + Referência: <code id="ref">--</code><br>
  74 + Ficheiro de configuração do teste: <code id="filename">--</code><br>
  75 + Testes em formato JSON no directório: <code id="answers_dir">--</code><br>
  76 + Base de dados: <code id="database">--</code><br>
  77 + </p>
  78 + <p>
  79 + <a href="/adminwebservice?cmd=testcsv" class="btn btn-primary">Obter CSV com as notas</a>
  80 + </p>
76 </div> <!-- jumbotron --> 81 </div> <!-- jumbotron -->
77 82
78 <table class="table table-sm table-striped" style="width:100%" id="students_table"> 83 <table class="table table-sm table-striped" style="width:100%" id="students_table">
79 - <thead class="thead thead-light">  
80 - <tr>  
81 - <th>#</th>  
82 - <th>Ok</th>  
83 - <th>Número</th>  
84 - <th>Nome</th>  
85 - <th>Estado</th>  
86 - <th>Nota</th>  
87 - </tr>  
88 - </thead> 84 + <thead class="thead thead-light">
  85 + <tr>
  86 + <th>#</th>
  87 + <th>Ok</th>
  88 + <th>Número</th>
  89 + <th>Nome</th>
  90 + <th>Estado</th>
  91 + <th>Nota</th>
  92 + </tr>
  93 + </thead>
89 </table> 94 </table>
90 95
91 </div> <!-- container --> 96 </div> <!-- container -->
perguntations/templates/grade.html
@@ -43,11 +43,11 @@ @@ -43,11 +43,11 @@
43 {% if t['state'] == 'FINISHED' %} 43 {% if t['state'] == 'FINISHED' %}
44 <h1>Resultado: 44 <h1>Resultado:
45 <strong>{{ f'{round(t["grade"], 1)}' }}</strong> 45 <strong>{{ f'{round(t["grade"], 1)}' }}</strong>
46 - valores na escala de {{t['scale_min']}} a {{t['scale_max']}}. 46 + valores na escala de {{t['scale'][0]}} a {{t['scale'][1]}}.
47 </h1> 47 </h1>
48 - <p>O seu teste foi registado.<br>  
49 - Pode fechar o browser e desligar o computador.</p>  
50 - {% if t['grade'] - t['scale_min'] >= 0.75*(t['scale_max'] - t['scale_min']) %} 48 + <p>O seu teste foi entregue e está registado.</p>
  49 + <p><a href="/" class="btn btn-primary btn-lg active" role="button">Clique aqui para sair do teste</a></p>
  50 + {% if t['grade'] - t['scale'][0] >= 0.75*(t['scale'][1] - t['scale'][0]) %}
51 <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i> 51 <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i>
52 {% end %} 52 {% end %}
53 {% elif t['state'] == 'QUIT' %} 53 {% elif t['state'] == 'QUIT' %}
@@ -78,19 +78,19 @@ @@ -78,19 +78,19 @@
78 <td> <!-- progress column --> 78 <td> <!-- progress column -->
79 <div class="progress" style="height: 20px;"> 79 <div class="progress" style="height: 20px;">
80 <div class="progress-bar 80 <div class="progress-bar
81 - {% if g[1] - t['scale_min'] < 0.5*(t['scale_max'] - t['scale_min']) %} 81 + {% if g[1] - t['scale'][0] < 0.5*(t['scale'][1] - t['scale'][0]) %}
82 bg-danger 82 bg-danger
83 - {% elif g[1] - t['scale_min'] < 0.75*(t['scale_max'] - t['scale_min']) %} 83 + {% elif g[1] - t['scale'][0] < 0.75*(t['scale'][1] - t['scale'][0]) %}
84 bg-warning 84 bg-warning
85 {% else %} 85 {% else %}
86 bg-success 86 bg-success
87 {% end %} 87 {% end %}
88 " 88 "
89 role="progressbar" 89 role="progressbar"
90 - aria-valuenow="{{ round(100*(g[1] - t['scale_min'])/(t['scale_max'] - t['scale_min'])) }}" 90 + aria-valuenow="{{ 100*(g[1] - t['scale'][0])/(t['scale'][1] - t['scale'][0]) }}"
91 aria-valuemin="0" 91 aria-valuemin="0"
92 aria-valuemax="100" 92 aria-valuemax="100"
93 - style="min-width: 2em; width: {{ round(100*(g[1]-t['scale_min'])/(t['scale_max']-t['scale_min'])) }}%;" 93 + style="min-width: 2em; width: {{ 100*(g[1]-t['scale'][0])/(t['scale'][1]-t['scale'][0]) }}%;"
94 > 94 >
95 95
96 {{ str(round(g[1], 1)) }} 96 {{ str(round(g[1], 1)) }}
perguntations/test.py
@@ -42,10 +42,8 @@ class TestFactory(dict): @@ -42,10 +42,8 @@ class TestFactory(dict):
42 # --- set test defaults and then use given configuration 42 # --- set test defaults and then use given configuration
43 super().__init__({ # defaults 43 super().__init__({ # defaults
44 'title': '', 44 'title': '',
45 - 'show_points': False,  
46 - 'scale_points': True,  
47 - 'scale_max': 20.0,  
48 - 'scale_min': 0.0, 45 + 'show_points': True,
  46 + 'scale': None, # or [0, 20]
49 'duration': 0, # 0=infinite 47 'duration': 0, # 0=infinite
50 'autosubmit': False, 48 'autosubmit': False,
51 'debug': False, 49 'debug': False,
@@ -197,11 +195,12 @@ class TestFactory(dict): @@ -197,11 +195,12 @@ class TestFactory(dict):
197 195
198 def check_grade_scaling(self): 196 def check_grade_scaling(self):
199 '''Just informs the scale limits''' 197 '''Just informs the scale limits'''
200 - if self['scale_points']:  
201 - smin, smax = self["scale_min"], self["scale_max"]  
202 - logger.info('Grades will be scaled to [%g, %g]', smin, smax)  
203 - else:  
204 - logger.info('Grades are not being scaled.') 198 + if 'scale_points' in self:
  199 + msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, '
  200 + 'scale_max were replaced by "scale: [min, max]".')
  201 + logger.warning(msg)
  202 + self['scale'] = [self['scale_min'], self['scale_max']]
  203 +
205 204
206 # ------------------------------------------------------------------------ 205 # ------------------------------------------------------------------------
207 def sanity_checks(self): 206 def sanity_checks(self):
@@ -254,25 +253,32 @@ class TestFactory(dict): @@ -254,25 +253,32 @@ class TestFactory(dict):
254 253
255 test.append(question) 254 test.append(question)
256 255
257 - # normalize question points to scale  
258 - if self['scale_points']:  
259 - total_points = sum(q['points'] for q in test)  
260 - if total_points == 0:  
261 - logger.warning('Can\'t scale, total points in the test is 0!')  
262 - else:  
263 - scale = (self['scale_max'] - self['scale_min']) / total_points 256 + # setup scale
  257 + total_points = sum(q['points'] for q in test)
  258 +
  259 + if total_points > 0:
  260 + # normalize question points to scale
  261 + if self['scale'] is not None:
  262 + scale_min, scale_max = self['scale']
264 for question in test: 263 for question in test:
265 - question['points'] *= scale 264 + question['points'] *= (scale_max - scale_min) / total_points
  265 + else:
  266 + self['scale'] = [0, total_points]
  267 + else:
  268 + logger.warning('Total points is **ZERO**.')
  269 + if self['scale'] is None:
  270 + self['scale'] = [0, 20]
266 271
267 if nerr > 0: 272 if nerr > 0:
268 logger.error('%s errors found!', nerr) 273 logger.error('%s errors found!', nerr)
269 274
  275 + # these will be copied to the test instance
270 inherit = {'ref', 'title', 'database', 'answers_dir', 276 inherit = {'ref', 'title', 'database', 'answers_dir',
271 'questions_dir', 'files', 277 'questions_dir', 'files',
272 'duration', 'autosubmit', 278 'duration', 'autosubmit',
273 - 'scale_min', 'scale_max', 'show_points', 279 + 'scale', 'show_points',
274 'show_ref', 'debug', } 280 'show_ref', 'debug', }
275 - # NOT INCLUDED: scale_points, testfile, allow_all, review 281 + # NOT INCLUDED: testfile, allow_all, review
276 282
277 return Test({ 283 return Test({
278 **{'student': student, 'questions': test}, 284 **{'student': student, 'questions': test},
@@ -318,6 +324,7 @@ class Test(dict): @@ -318,6 +324,7 @@ class Test(dict):
318 '''Corrects all the answers of the test and computes the final grade''' 324 '''Corrects all the answers of the test and computes the final grade'''
319 self['finish_time'] = datetime.now() 325 self['finish_time'] = datetime.now()
320 self['state'] = 'FINISHED' 326 self['state'] = 'FINISHED'
  327 +
321 grade = 0.0 328 grade = 0.0
322 for question in self['questions']: 329 for question in self['questions']:
323 await question.correct_async() 330 await question.correct_async()
@@ -325,8 +332,8 @@ class Test(dict): @@ -325,8 +332,8 @@ class Test(dict):
325 logger.debug('Correcting %30s: %3g%%', 332 logger.debug('Correcting %30s: %3g%%',
326 question["ref"], question["grade"]*100) 333 question["ref"], question["grade"]*100)
327 334
328 - # truncate to avoid negative grade and adjust scale  
329 - self['grade'] = max(0.0, grade) + self['scale_min'] 335 + # truncate to avoid negative final grade and adjust scale
  336 + self['grade'] = max(0.0, grade) + self['scale'][0]
330 return self['grade'] 337 return self['grade']
331 338
332 # ------------------------------------------------------------------------ 339 # ------------------------------------------------------------------------