Commit 526721fdac27a947ac5c001137c29fc6ba40f164

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

- changed text-numeric to numeric-interval

- many changes and fixes. review still hardcoded...
1 1
2 # BUGS 2 # BUGS
3 3
4 -- testar perguntas warning/warn  
5 -- text-numeric não está a gerar a pergunta. faltam templates? 4 +- numeracao das perguntas do teste esta a contar com paineis informativos...
  5 +- servir imagens das perguntas
  6 +- review de um teste nao funciona (hardcoded...)
  7 +- testar opcao --allow-all
  8 +- como alterar configuracao para mostrar logs de debug?
  9 +- hints nao funciona
6 - uniformizar question.py com a de aprendizations... 10 - uniformizar question.py com a de aprendizations...
7 - Review de um teste que foi apagado rebenta. 11 - Review de um teste que foi apagado rebenta.
8 - permitir eliminar teste a decorrer de modo a que o aluno possa recomeçar (e.g. noutro browser) 12 - permitir eliminar teste a decorrer de modo a que o aluno possa recomeçar (e.g. noutro browser)
@@ -11,13 +15,10 @@ @@ -11,13 +15,10 @@
11 # TODO 15 # TODO
12 16
13 - Gerar pdf's com todos os testes no final (pdfkit). 17 - Gerar pdf's com todos os testes no final (pdfkit).
14 -- testar SSL  
15 - manter registo dos unfocus durante o teste e de qual a pergunta visivel nesse momento 18 - manter registo dos unfocus durante o teste e de qual a pergunta visivel nesse momento
16 19
17 - permitir varios testes, aluno escolhe qual o teste que quer fazer. 20 - permitir varios testes, aluno escolhe qual o teste que quer fazer.
18 -- usar thread.Lock para aceder a variaveis de estado?  
19 - se ocorrer um erro na correcçao avisar aluno para contactar o professor. 21 - se ocorrer um erro na correcçao avisar aluno para contactar o professor.
20 -- implementar practice mode?  
21 - abrir o teste numa janela maximizada e que nao permite que o aluno a redimensione/mova? 22 - abrir o teste numa janela maximizada e que nao permite que o aluno a redimensione/mova?
22 - 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) 23 - 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)
23 - single page web no teste/correcçao. Página construída em javascript, obter perguntas com ajax (para practice?). 24 - single page web no teste/correcçao. Página construída em javascript, obter perguntas com ajax (para practice?).
@@ -30,6 +31,9 @@ @@ -30,6 +31,9 @@
30 31
31 # FIXED 32 # FIXED
32 33
  34 +- testar SSL
  35 +- text-numeric não está a gerar a pergunta. faltam templates?
  36 +- testar perguntas warning/warn
33 - qd user 0 faz logout rebenta. 37 - qd user 0 faz logout rebenta.
34 - Quando grava JSON do teste deve usar 'path' tal como definido na configuração e não expandido. Isto porque em OSX /home é /Users e quando se muda de um sistema para outro não encontra os testes. Assim, usando ~ na configuração deveria funcionar sempre. 38 - Quando grava JSON do teste deve usar 'path' tal como definido na configuração e não expandido. Isto porque em OSX /home é /Users e quando se muda de um sistema para outro não encontra os testes. Assim, usando ~ na configuração deveria funcionar sempre.
35 - configuração do teste não joga bem com o do aprendizations. Em particular os scripts não ficam com o mesmo path!!! 39 - configuração do teste não joga bem com o do aprendizations. Em particular os scripts não ficam com o mesmo path!!!
demo/questions/questions-tutorial.yaml 0 → 100644
@@ -0,0 +1,92 @@ @@ -0,0 +1,92 @@
  1 +-
  2 + ref: tut-information
  3 + type: information
  4 + title: information (ou info)
  5 + text: Texto informativo. Não conta para avaliação.
  6 +# ---------------------------------------------------------------------------
  7 +-
  8 + ref: tut-success
  9 + type: success
  10 + title: success
  11 + text: Texto de positivo (sucesso). Não conta para avaliação.
  12 +# ---------------------------------------------------------------------------
  13 +-
  14 + ref: tut-warning
  15 + type: warning
  16 + title: warning (ou warn)
  17 + text: Texto de aviso. Não conta para avaliação.
  18 +# ----------------------------------------------------------------------------
  19 +-
  20 + ref: tut-alert
  21 + type: alert
  22 + title: alert
  23 + text: Texto negativo (alerta). Não conta para avaliação.
  24 +# ----------------------------------------------------------------------------
  25 +-
  26 + ref: tut-radio
  27 + type: radio
  28 + title: radio
  29 + text: Escolha simples, apenas uma opção está correcta.
  30 + options:
  31 + - Opção 0 (correcta)
  32 + - Opção 1
  33 + - Opção 2
  34 + - Opção 3
  35 + # opcionais e valores por defeito
  36 + shuffle: True
  37 + correct: 0
  38 +# ----------------------------------------------------------------------------
  39 +-
  40 + ref: tut-checkbox
  41 + type: checkbox
  42 + title: checkbox
  43 + text: Escolha simples, apenas uma opção está correcta.
  44 + options:
  45 + - Opção 0 (sim)
  46 + - Opção 1 (não)
  47 + - Opção 2 (não)
  48 + - Opção 3 (sim)
  49 + correct: [1,-1,-1,1]
  50 + # opcionais e valores por defeito
  51 + shuffle: True
  52 +# ----------------------------------------------------------------------------
  53 +-
  54 + ref: tut-text
  55 + type: text
  56 + title: text
  57 + text: |
  58 + Resposta numa linha de texto. A resposta está correcta se coincidir com alguma das respostas admissíveis.
  59 + Neste exemplo a resposta correcta é `azul`, `Azul` ou `AZUL`.
  60 + correct: ['azul', 'Azul', 'AZUL']
  61 +# ---------------------------------------------------------------------------
  62 +-
  63 + ref: tut-text-regex
  64 + type: text-regex
  65 + title: text-regex
  66 + text: |
  67 + Resposta numa linha de texto. A resposta é validada com uma expressão regular.
  68 + Neste exemplo a expressão regular é `(VERDE|[Vv]erde)`.
  69 + correct: !regex '(VERDE|[Vv]erde)'
  70 +# ---------------------------------------------------------------------------
  71 +-
  72 + ref: tut-numeric-interval
  73 + type: numeric-interval
  74 + title: numeric-interval
  75 + text: |
  76 + Resposta numérica numa linha de texto. A resposta é convertida para um float e tem de pertencer a um intervalo de valores.
  77 + Neste exemplo o intervalo é [3.14, 3.15].
  78 + correct: [3.14, 3.15]
  79 +# ---------------------------------------------------------------------------
  80 +-
  81 + ref: tut-textarea
  82 + type: textarea
  83 + title: textarea
  84 + text: |
  85 + Resposta num bloco de texto que pode ser usado para introduzir código.
  86 + A resposta é avaliada por um programa externo.
  87 + O programa externo, recebe a resposta no stdin e devolve a classificação no stdout.
  88 + Neste exemplo, o programa de avaliação verifica se a resposta contém as três palavras red, green e blue.
  89 + correct: correct/correct-question.py
  90 + # opcionais e defaults
  91 + lines: 3
  92 + timeout: 5
demo/test-tutorial.yaml 0 → 100644
@@ -0,0 +1,56 @@ @@ -0,0 +1,56 @@
  1 +#=============================================================================
  2 +# The test reference should be a unique identifier. It is saved in the database
  3 +# so that queries for the results can be done in the terminal with
  4 +# $ sqlite3 students.db "select * from tests where ref='demo'"
  5 +ref: tutorial
  6 +
  7 +# (optional, default: '') You may wish to refer the course, year or kind of test
  8 +title: Teste tutorial
  9 +
  10 +# (optional) duration in minutes FIXME
  11 +duration: 90
  12 +
  13 +# Database with student credentials and grades of all questions and tests done
  14 +# The database is an sqlite3 file generate with the script initdb.py
  15 +database: demo/students.db
  16 +
  17 +# Generate a file for each test done by a student.
  18 +# It includes the questions, answers and grades.
  19 +answers_dir: demo/ans
  20 +
  21 +# (optional, default: False) Show points for each question, scale 0-20.
  22 +show_points: True
  23 +
  24 +# (optional, default: False) Show hints if available
  25 +show_hints: True
  26 +
  27 +# (optional, default: False) Show lots of information for debugging
  28 +# debug: True
  29 +
  30 +#-----------------------------------------------------------------------------
  31 +# Base path applied to the questions files and all the scripts
  32 +# including question generators and correctors.
  33 +# Either absolute path or relative to current directory can be used.
  34 +questions_dir: demo/questions
  35 +
  36 +# (optional) List of files containing questions in yaml format.
  37 +# Selected questions will be obtained from these files.
  38 +# If undefined, all yaml files in questions_dir are loaded (not recommended).
  39 +files:
  40 + - questions-tutorial.yaml
  41 +
  42 +# This is the list of questions that will make up the test.
  43 +# The order is preserved.
  44 +# There are several ways to define each question (explained below).
  45 +questions:
  46 + - tut-information
  47 + - tut-success
  48 + - tut-warning
  49 + - tut-alert
  50 +
  51 + - tut-radio
  52 + - tut-checkbox
  53 + - tut-text
  54 + - tut-text-regex
  55 + - tut-numeric-interval
  56 + - tut-textarea
@@ -275,7 +275,7 @@ class QuestionTextRegex(Question): @@ -275,7 +275,7 @@ class QuestionTextRegex(Question):
275 275
276 276
277 # =========================================================================== 277 # ===========================================================================
278 -class QuestionTextNumeric(Question): 278 +class QuestionNumericInterval(Question):
279 '''An instance of QuestionTextNumeric will always have the keys: 279 '''An instance of QuestionTextNumeric will always have the keys:
280 type (str) 280 type (str)
281 text (str) 281 text (str)
@@ -302,6 +302,7 @@ class QuestionTextNumeric(Question): @@ -302,6 +302,7 @@ class QuestionTextNumeric(Question):
302 try: 302 try:
303 answer = float(self['answer']) 303 answer = float(self['answer'])
304 304
  305 + # TODO:
305 # alternative using locale (1.2 vs 1,2) 306 # alternative using locale (1.2 vs 1,2)
306 # import locale 307 # import locale
307 # locale.setlocale(locale.LC_ALL, 'pt_PT') 308 # locale.setlocale(locale.LC_ALL, 'pt_PT')
@@ -401,8 +402,8 @@ class QuestionFactory(dict): @@ -401,8 +402,8 @@ class QuestionFactory(dict):
401 'radio' : QuestionRadio, 402 'radio' : QuestionRadio,
402 'checkbox' : QuestionCheckbox, 403 'checkbox' : QuestionCheckbox,
403 'text' : QuestionText, 404 'text' : QuestionText,
404 - 'text_regex': QuestionTextRegex, 'text-regex': QuestionTextRegex,  
405 - 'text_numeric': QuestionTextNumeric, 'text-numeric': QuestionTextNumeric, 405 + 'text-regex': QuestionTextRegex,
  406 + 'numeric-interval': QuestionNumericInterval,
406 'textarea' : QuestionTextArea, 407 'textarea' : QuestionTextArea,
407 # -- informative panels -- 408 # -- informative panels --
408 'information': QuestionInformation, 'info': QuestionInformation, 409 'information': QuestionInformation, 'info': QuestionInformation,
@@ -420,22 +421,17 @@ class QuestionFactory(dict): @@ -420,22 +421,17 @@ class QuestionFactory(dict):
420 # Add single question provided in a dictionary. 421 # Add single question provided in a dictionary.
421 # After this, each question will have at least 'ref' and 'type' keys. 422 # After this, each question will have at least 'ref' and 'type' keys.
422 # ----------------------------------------------------------------------- 423 # -----------------------------------------------------------------------
423 - def add(self, question):  
424 - # if ref missing try ref='/path/file.yaml:3'  
425 - try:  
426 - question.setdefault('ref', question['filename'] + ':' + str(question['index']))  
427 - except KeyError:  
428 - logger.error('Missing "ref". Cannot add question to the pool.')  
429 - return 424 + def add_question(self, question):
  425 + # if missing defaults to ref='/path/file.yaml:3'
  426 + question.setdefault('ref', f'{question["filename"]}:{question["index"]}')
430 427
431 - # check duplicate references  
432 if question['ref'] in self: 428 if question['ref'] in self:
433 - logger.error(f'Duplicate reference "{question["ref"]}". Replacing the original one!') 429 + logger.error(f'Duplicate reference "{question["ref"]}" replaces the original.')
434 430
435 question.setdefault('type', 'information') 431 question.setdefault('type', 'information')
436 432
437 self[question['ref']] = question 433 self[question['ref']] = question
438 - logger.debug('Added question "{0}" to the pool.'.format(question['ref'])) 434 + logger.debug(f'Added question "{question["ref"]}" to the pool.')
439 435
440 # ----------------------------------------------------------------------- 436 # -----------------------------------------------------------------------
441 # load single YAML questions file 437 # load single YAML questions file
@@ -453,20 +449,19 @@ class QuestionFactory(dict): @@ -453,20 +449,19 @@ class QuestionFactory(dict):
453 449
454 questions = load_yaml(fullpath, default=[]) 450 questions = load_yaml(fullpath, default=[])
455 451
456 - n = 0  
457 for i, q in enumerate(questions): 452 for i, q in enumerate(questions):
458 - if isinstance(q, dict): 453 + try:
459 q.update({ 454 q.update({
460 'filename': filename, 455 'filename': filename,
461 'path': dirname, 456 'path': dirname,
462 - 'index': i # position in the file, 0 based 457 + 'index': i # position in the file, 0 based
463 }) 458 })
464 - self.add(q) # add question  
465 - n += 1 # counter  
466 - else:  
467 - logger.error(f'Question index {i} from file {pathfile} is not a dictionary. Skipped!') 459 + except AttributeError:
  460 + logger.error(f'Question {pathfile}:{i} is not a dictionary. Skipped!')
  461 + else:
  462 + self.add_question(q)
468 463
469 - logger.info(f'Loaded {n} questions from "{pathfile}".') 464 + logger.info(f'Loaded {len(self)} questions from "{pathfile}".')
470 465
471 # ----------------------------------------------------------------------- 466 # -----------------------------------------------------------------------
472 # load multiple YAML question files 467 # load multiple YAML question files
1 -#!/usr/bin/env python3 1 +#!/usr/bin/env python3.6
2 2
3 # base 3 # base
4 from os import path 4 from os import path
@@ -26,7 +26,7 @@ class WebApplication(tornado.web.Application): @@ -26,7 +26,7 @@ class WebApplication(tornado.web.Application):
26 (r'/login', LoginHandler), 26 (r'/login', LoginHandler),
27 (r'/logout', LogoutHandler), 27 (r'/logout', LogoutHandler),
28 (r'/test', TestHandler), 28 (r'/test', TestHandler),
29 - (r'/review', ReviewHandler), # FIXME 29 + (r'/review', ReviewHandler),
30 (r'/admin', AdminHandler), 30 (r'/admin', AdminHandler),
31 # (r'/change_password', ChangePasswordHandler), 31 # (r'/change_password', ChangePasswordHandler),
32 (r'/static/(.+)', FileHandler), # FIXME 32 (r'/static/(.+)', FileHandler), # FIXME
@@ -76,7 +76,7 @@ class LoginHandler(BaseHandler): @@ -76,7 +76,7 @@ class LoginHandler(BaseHandler):
76 self.set_secure_cookie("user", str(uid), expires_days=30) 76 self.set_secure_cookie("user", str(uid), expires_days=30)
77 self.redirect(self.get_argument("next", "/")) 77 self.redirect(self.get_argument("next", "/"))
78 else: 78 else:
79 - self.render("login.html", error='Número ou senha incorrectos') 79 + self.render("login.html", error='Não autorizado ou número/senha inválido')
80 80
81 # ------------------------------------------------------------------------- 81 # -------------------------------------------------------------------------
82 class LogoutHandler(BaseHandler): 82 class LogoutHandler(BaseHandler):
@@ -110,10 +110,8 @@ class TestHandler(BaseHandler): @@ -110,10 +110,8 @@ class TestHandler(BaseHandler):
110 'radio': 'question-radio.html', 110 'radio': 'question-radio.html',
111 'checkbox': 'question-checkbox.html', 111 'checkbox': 'question-checkbox.html',
112 'text': 'question-text.html', 112 'text': 'question-text.html',
113 - # 'text_regex': 'question-text.html',  
114 'text-regex': 'question-text.html', 113 'text-regex': 'question-text.html',
115 - # 'text_numeric': 'question-text.html',  
116 - 'text-numeric': 'question-text.html', 114 + 'numeric-interval': 'question-text.html',
117 'textarea': 'question-textarea.html', 115 'textarea': 'question-textarea.html',
118 # -- information panels -- 116 # -- information panels --
119 'information': 'question-information.html', 117 'information': 'question-information.html',
@@ -153,7 +151,7 @@ class TestHandler(BaseHandler): @@ -153,7 +151,7 @@ class TestHandler(BaseHandler):
153 ans[i] = None 151 ans[i] = None
154 else: 152 else:
155 ans[i] = ans[i][0] 153 ans[i] = ans[i][0]
156 - elif q['type'] in ('textarea', 'text', 'text-numeric', 'text-regex'): 154 + elif q['type'] in ('text', 'text-regex', 'textarea', 'numeric-interval'):
157 ans[i] = ans[i][0] 155 ans[i] = ans[i][0]
158 156
159 self.testapp.correct_test(uid, ans) 157 self.testapp.correct_test(uid, ans)
@@ -170,10 +168,12 @@ class ReviewHandler(BaseHandler): @@ -170,10 +168,12 @@ class ReviewHandler(BaseHandler):
170 'checkbox': 'review-question-checkbox.html', 168 'checkbox': 'review-question-checkbox.html',
171 'text': 'review-question-text.html', 169 'text': 'review-question-text.html',
172 'text-regex': 'review-question-text.html', 170 'text-regex': 'review-question-text.html',
173 - 'text-numeric': 'review-question-text.html', 171 + 'numeric-interval': 'review-question-text.html',
174 'textarea': 'review-question-text.html', 172 'textarea': 'review-question-text.html',
175 # -- information panels -- 173 # -- information panels --
  174 + 'information': 'question-information.html',
176 'info': 'question-information.html', 175 'info': 'question-information.html',
  176 + 'warning': 'question-warning.html',
177 'warn': 'question-warning.html', 177 'warn': 'question-warning.html',
178 'alert': 'question-alert.html', 178 'alert': 'question-alert.html',
179 'success': 'question-success.html', 179 'success': 'question-success.html',
@@ -322,6 +322,7 @@ def main(): @@ -322,6 +322,7 @@ def main():
322 logging.critical('Failed to start application.') 322 logging.critical('Failed to start application.')
323 sys.exit(1) 323 sys.exit(1)
324 324
  325 + # --- start web application
325 try: 326 try:
326 webapp = WebApplication(app, debug=arg.debug) 327 webapp = WebApplication(app, debug=arg.debug)
327 except Exception as e: 328 except Exception as e:
static/css/test.css
1 /* Fixes navigation panel overlaying content */ 1 /* Fixes navigation panel overlaying content */
  2 +html {
  3 + font-size: 14px;
  4 +}
  5 +
2 body { 6 body {
3 padding-top: 100px; /* make room at top of page for the navbar */ 7 padding-top: 100px; /* make room at top of page for the navbar */
4 background: #aaa; 8 background: #aaa;
templates/question-success.html
1 {% autoescape %} 1 {% autoescape %}
2 2
3 3
4 -<div class="alert alert-success" role="alert"> 4 +<div class="alert alert-success border-success" role="alert">
5 <h3> 5 <h3>
6 {{ question['title'] }} 6 {{ question['title'] }}
7 </h3> 7 </h3>
templates/question-warning.html
1 {% autoescape %} 1 {% autoescape %}
2 2
3 3
4 -<div class="alert alert-warning" role="alert"> 4 +<div class="alert alert-warning border-warning" role="alert">
5 <h3> 5 <h3>
6 {{ question['title'] }} 6 {{ question['title'] }}
7 </h3> 7 </h3>
templates/question.html
@@ -18,7 +18,7 @@ @@ -18,7 +18,7 @@
18 18
19 <p class="text-right"> 19 <p class="text-right">
20 <small> 20 <small>
21 - (Cotação: {{ question['points'] }} pontos não normalizados) 21 + (Cotação: {{ round(question['points'], 2) }} pontos)
22 </small> 22 </small>
23 </p> 23 </p>
24 24
templates/review-question.html
@@ -25,38 +25,39 @@ @@ -25,38 +25,39 @@
25 {% if t['show_points'] %} 25 {% if t['show_points'] %}
26 <p class="text-right"> 26 <p class="text-right">
27 <small> 27 <small>
28 - (Cotação: {{ q['points'] }} pontos não normalizados) 28 + (Cotação: {{ q['points'] }})
29 </small> 29 </small>
30 </p> 30 </p>
31 {% end %} 31 {% end %}
  32 + </div> <!-- card-body -->
  33 +
  34 + {% if t['state'] == 'FINISHED' %}
  35 + <div class="card-footer">
  36 + {% if q['grade'] > 0.99 %}
  37 + <p class="text-success">
  38 + <i class="fa fa-thumbs-o-up" aria-hidden="true"></i>
  39 + {{ round(q['grade'] * q['points'], 2) }}
  40 + pontos<br>
  41 + {{ q['comments'] }}
  42 + </p>
  43 + {% elif q['grade'] > 0.49 %}
  44 + <p class="text-warning">
  45 + <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>
  46 + {{ round(q['grade'] * q['points'], 2) }}
  47 + pontos<br>
  48 + {{ q['comments'] }}
  49 + </p>
  50 + {% else %}
  51 + <p class="text-danger">
  52 + <i class="fa fa-thumbs-o-down" aria-hidden="true"></i>
  53 + {{ round(q['grade'] * q['points'], 2) }}
  54 + pontos<br>
  55 + {{ q['comments'] }}
  56 + </p>
  57 + {% end %}
  58 + </div> <!-- card-footer -->
  59 + {% end %}
32 60
33 - {% if t['state'] == 'FINISHED' %}  
34 - <div class="card-footer">  
35 - {% if q['grade'] > 0.99 %}  
36 - <p class="text-success">  
37 - <i class="fa fa-thumbs-o-up" aria-hidden="true"></i>  
38 - {{ round(q['grade'] * q['points'] / total_points * 20.0, 2)}}  
39 - pontos<br>  
40 - {{ q['comments'] }}  
41 - </p>  
42 - {% elif q['grade'] > 0.49 %}  
43 - <p class="text-warning">  
44 - <i class="fa fa-exclamation-triangle" aria-hidden="true"></i>  
45 - {{ round(q['grade'] * q['points'] / total_points * 20.0, 2) }}  
46 - pontos<br>  
47 - {{ q['comments'] }}  
48 - </p>  
49 - {% else %}  
50 - <p class="text-danger">  
51 - <i class="fa fa-thumbs-o-down" aria-hidden="true"></i>  
52 - {{ round(q['grade'] * q['points'] / total_points * 20.0, 2) }}  
53 - pontos<br>  
54 - {{ q['comments'] }}  
55 - </p>  
56 - {% end %}  
57 - </div>  
58 - {% end %}  
59 - </div>  
60 61
61 </div> 62 </div>
62 {% end %} 63 {% end %}
63 \ No newline at end of file 64 \ No newline at end of file
templates/test.html
@@ -28,9 +28,9 @@ @@ -28,9 +28,9 @@
28 <!-- ===================================================================== --> 28 <!-- ===================================================================== -->
29 <body> 29 <body>
30 30
31 -<nav class="navbar navbar-expand-lg fixed-top navbar-dark bg-dark"> 31 +<nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-dark">
32 <a class="navbar-brand" href="#"> 32 <a class="navbar-brand" href="#">
33 - <i class="fa fa-clock-o" aria-hidden="true"></i> 33 + <!-- <i class="fa fa-clock-o" aria-hidden="true"></i> -->
34 <span id="clock"> --:-- </span> 34 <span id="clock"> --:-- </span>
35 </a> 35 </a>
36 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> 36 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
@@ -129,9 +129,12 @@ class TestFactory(dict): @@ -129,9 +129,12 @@ class TestFactory(dict):
129 self.setdefault('title', '') 129 self.setdefault('title', '')
130 self.setdefault('show_hints', False) 130 self.setdefault('show_hints', False)
131 self.setdefault('show_points', False) 131 self.setdefault('show_points', False)
  132 + self.setdefault('scale_points', True)
  133 + self.setdefault('scale_max', 20.0)
  134 + self.setdefault('duration', 0) # FIXME unused
  135 +
132 self.setdefault('debug', False) 136 self.setdefault('debug', False)
133 self.setdefault('show_ref', False) 137 self.setdefault('show_ref', False)
134 - self.setdefault('duration', 0) # FIXME unused  
135 138
136 139
137 # ----------------------------------------------------------------------- 140 # -----------------------------------------------------------------------
@@ -140,6 +143,8 @@ class TestFactory(dict): @@ -140,6 +143,8 @@ class TestFactory(dict):
140 # ----------------------------------------------------------------------- 143 # -----------------------------------------------------------------------
141 def generate(self, student): 144 def generate(self, student):
142 test = [] 145 test = []
  146 + total_points = 0.0
  147 +
143 for qq in self['questions']: 148 for qq in self['questions']:
144 # generate Question() selected randomly from list of references 149 # generate Question() selected randomly from list of references
145 qref = random.choice(qq['ref']) 150 qref = random.choice(qq['ref'])
@@ -151,19 +156,26 @@ class TestFactory(dict): @@ -151,19 +156,26 @@ class TestFactory(dict):
151 continue 156 continue
152 157
153 # some defaults 158 # some defaults
154 - if q['type'] in ('information', 'warning', 'alert'): 159 + if q['type'] in ('information', 'success', 'warning', 'alert'):
155 q['points'] = qq.get('points', 0.0) 160 q['points'] = qq.get('points', 0.0)
156 else: 161 else:
157 q['points'] = qq.get('points', 1.0) 162 q['points'] = qq.get('points', 1.0)
158 163
  164 + total_points += q['points']
159 test.append(q) 165 test.append(q)
160 166
  167 + # normalize question points to scale
  168 + if self['scale_points']:
  169 + for q in test:
  170 + q['points'] *= self['scale_max'] / total_points
  171 +
161 return Test({ 172 return Test({
162 'ref': self['ref'], 173 'ref': self['ref'],
163 'title': self['title'], # title of the test 174 'title': self['title'], # title of the test
164 'student': student, # student id 175 'student': student, # student id
165 'questions': test, # list of questions 176 'questions': test, # list of questions
166 'answers_dir': self['answers_dir'], 177 'answers_dir': self['answers_dir'],
  178 + # 'total_points': total_points,
167 179
168 # FIXME which ones are required? 180 # FIXME which ones are required?
169 'show_hints': self['show_hints'], 181 'show_hints': self['show_hints'],
@@ -218,17 +230,8 @@ class Test(dict): @@ -218,17 +230,8 @@ class Test(dict):
218 self['finish_time'] = datetime.now() 230 self['finish_time'] = datetime.now()
219 self['state'] = 'FINISHED' 231 self['state'] = 'FINISHED'
220 232
221 - grade = 0.0  
222 - total_points = 0.0  
223 - for q in self['questions']:  
224 - grade += q.correct() * q['points']  
225 - total_points += q['points']  
226 -  
227 - if total_points > 0.0:  
228 - self['grade'] = round(20.0 * max(grade / total_points, 0.0), 1)  
229 - else:  
230 - logger.error(f'Student {self["student"]["number"]}: division by zero during correction. Total points must be positive.')  
231 - self['grade'] = 0.0 233 + grade = sum(q.correct()*q['points'] for q in self['questions'])
  234 + self['grade'] = round(grade, 1)
232 235
233 logger.info(f'Student {self["student"]["number"]}: correction gave {self["grade"]} points.') 236 logger.info(f'Student {self["student"]["number"]}: correction gave {self["grade"]} points.')
234 return self['grade'] 237 return self['grade']