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...
BUGS.md
1 1  
2 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 10 - uniformizar question.py com a de aprendizations...
7 11 - Review de um teste que foi apagado rebenta.
8 12 - permitir eliminar teste a decorrer de modo a que o aluno possa recomeçar (e.g. noutro browser)
... ... @@ -11,13 +15,10 @@
11 15 # TODO
12 16  
13 17 - Gerar pdf's com todos os testes no final (pdfkit).
14   -- testar SSL
15 18 - manter registo dos unfocus durante o teste e de qual a pergunta visivel nesse momento
16 19  
17 20 - permitir varios testes, aluno escolhe qual o teste que quer fazer.
18   -- usar thread.Lock para aceder a variaveis de estado?
19 21 - se ocorrer um erro na correcçao avisar aluno para contactar o professor.
20   -- implementar practice mode?
21 22 - abrir o teste numa janela maximizada e que nao permite que o aluno a redimensione/mova?
22 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 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 31  
31 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 37 - qd user 0 faz logout rebenta.
34 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 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 @@
  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 @@
  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
... ...
questions.py
... ... @@ -275,7 +275,7 @@ class QuestionTextRegex(Question):
275 275  
276 276  
277 277 # ===========================================================================
278   -class QuestionTextNumeric(Question):
  278 +class QuestionNumericInterval(Question):
279 279 '''An instance of QuestionTextNumeric will always have the keys:
280 280 type (str)
281 281 text (str)
... ... @@ -302,6 +302,7 @@ class QuestionTextNumeric(Question):
302 302 try:
303 303 answer = float(self['answer'])
304 304  
  305 + # TODO:
305 306 # alternative using locale (1.2 vs 1,2)
306 307 # import locale
307 308 # locale.setlocale(locale.LC_ALL, 'pt_PT')
... ... @@ -401,8 +402,8 @@ class QuestionFactory(dict):
401 402 'radio' : QuestionRadio,
402 403 'checkbox' : QuestionCheckbox,
403 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 407 'textarea' : QuestionTextArea,
407 408 # -- informative panels --
408 409 'information': QuestionInformation, 'info': QuestionInformation,
... ... @@ -420,22 +421,17 @@ class QuestionFactory(dict):
420 421 # Add single question provided in a dictionary.
421 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 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 431 question.setdefault('type', 'information')
436 432  
437 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 437 # load single YAML questions file
... ... @@ -453,20 +449,19 @@ class QuestionFactory(dict):
453 449  
454 450 questions = load_yaml(fullpath, default=[])
455 451  
456   - n = 0
457 452 for i, q in enumerate(questions):
458   - if isinstance(q, dict):
  453 + try:
459 454 q.update({
460 455 'filename': filename,
461 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 467 # load multiple YAML question files
... ...
serve.py
1   -#!/usr/bin/env python3
  1 +#!/usr/bin/env python3.6
2 2  
3 3 # base
4 4 from os import path
... ... @@ -26,7 +26,7 @@ class WebApplication(tornado.web.Application):
26 26 (r'/login', LoginHandler),
27 27 (r'/logout', LogoutHandler),
28 28 (r'/test', TestHandler),
29   - (r'/review', ReviewHandler), # FIXME
  29 + (r'/review', ReviewHandler),
30 30 (r'/admin', AdminHandler),
31 31 # (r'/change_password', ChangePasswordHandler),
32 32 (r'/static/(.+)', FileHandler), # FIXME
... ... @@ -76,7 +76,7 @@ class LoginHandler(BaseHandler):
76 76 self.set_secure_cookie("user", str(uid), expires_days=30)
77 77 self.redirect(self.get_argument("next", "/"))
78 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 82 class LogoutHandler(BaseHandler):
... ... @@ -110,10 +110,8 @@ class TestHandler(BaseHandler):
110 110 'radio': 'question-radio.html',
111 111 'checkbox': 'question-checkbox.html',
112 112 'text': 'question-text.html',
113   - # 'text_regex': 'question-text.html',
114 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 115 'textarea': 'question-textarea.html',
118 116 # -- information panels --
119 117 'information': 'question-information.html',
... ... @@ -153,7 +151,7 @@ class TestHandler(BaseHandler):
153 151 ans[i] = None
154 152 else:
155 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 155 ans[i] = ans[i][0]
158 156  
159 157 self.testapp.correct_test(uid, ans)
... ... @@ -170,10 +168,12 @@ class ReviewHandler(BaseHandler):
170 168 'checkbox': 'review-question-checkbox.html',
171 169 'text': 'review-question-text.html',
172 170 'text-regex': 'review-question-text.html',
173   - 'text-numeric': 'review-question-text.html',
  171 + 'numeric-interval': 'review-question-text.html',
174 172 'textarea': 'review-question-text.html',
175 173 # -- information panels --
  174 + 'information': 'question-information.html',
176 175 'info': 'question-information.html',
  176 + 'warning': 'question-warning.html',
177 177 'warn': 'question-warning.html',
178 178 'alert': 'question-alert.html',
179 179 'success': 'question-success.html',
... ... @@ -322,6 +322,7 @@ def main():
322 322 logging.critical('Failed to start application.')
323 323 sys.exit(1)
324 324  
  325 + # --- start web application
325 326 try:
326 327 webapp = WebApplication(app, debug=arg.debug)
327 328 except Exception as e:
... ...
static/css/test.css
1 1 /* Fixes navigation panel overlaying content */
  2 +html {
  3 + font-size: 14px;
  4 +}
  5 +
2 6 body {
3 7 padding-top: 100px; /* make room at top of page for the navbar */
4 8 background: #aaa;
... ...
templates/question-success.html
1 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 5 <h3>
6 6 {{ question['title'] }}
7 7 </h3>
... ...
templates/question-warning.html
1 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 5 <h3>
6 6 {{ question['title'] }}
7 7 </h3>
... ...
templates/question.html
... ... @@ -18,7 +18,7 @@
18 18  
19 19 <p class="text-right">
20 20 <small>
21   - (Cotação: {{ question['points'] }} pontos não normalizados)
  21 + (Cotação: {{ round(question['points'], 2) }} pontos)
22 22 </small>
23 23 </p>
24 24  
... ...
templates/review-question.html
... ... @@ -25,38 +25,39 @@
25 25 {% if t['show_points'] %}
26 26 <p class="text-right">
27 27 <small>
28   - (Cotação: {{ q['points'] }} pontos não normalizados)
  28 + (Cotação: {{ q['points'] }})
29 29 </small>
30 30 </p>
31 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 62 </div>
62 63 {% end %}
63 64 \ No newline at end of file
... ...
templates/test.html
... ... @@ -28,9 +28,9 @@
28 28 <!-- ===================================================================== -->
29 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 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 34 <span id="clock"> --:-- </span>
35 35 </a>
36 36 <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
... ...
test.py
... ... @@ -129,9 +129,12 @@ class TestFactory(dict):
129 129 self.setdefault('title', '')
130 130 self.setdefault('show_hints', False)
131 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 136 self.setdefault('debug', False)
133 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 143 # -----------------------------------------------------------------------
141 144 def generate(self, student):
142 145 test = []
  146 + total_points = 0.0
  147 +
143 148 for qq in self['questions']:
144 149 # generate Question() selected randomly from list of references
145 150 qref = random.choice(qq['ref'])
... ... @@ -151,19 +156,26 @@ class TestFactory(dict):
151 156 continue
152 157  
153 158 # some defaults
154   - if q['type'] in ('information', 'warning', 'alert'):
  159 + if q['type'] in ('information', 'success', 'warning', 'alert'):
155 160 q['points'] = qq.get('points', 0.0)
156 161 else:
157 162 q['points'] = qq.get('points', 1.0)
158 163  
  164 + total_points += q['points']
159 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 172 return Test({
162 173 'ref': self['ref'],
163 174 'title': self['title'], # title of the test
164 175 'student': student, # student id
165 176 'questions': test, # list of questions
166 177 'answers_dir': self['answers_dir'],
  178 + # 'total_points': total_points,
167 179  
168 180 # FIXME which ones are required?
169 181 'show_hints': self['show_hints'],
... ... @@ -218,17 +230,8 @@ class Test(dict):
218 230 self['finish_time'] = datetime.now()
219 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 236 logger.info(f'Student {self["student"]["number"]}: correction gave {self["grade"]} points.')
234 237 return self['grade']
... ...