Commit acf932552121cf8434bc9303fdada33136b44e24

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

- cosmetic changes in the test and results template.

- simplified code in some questions.
- added more error checks.
BUGS.md
1 1  
2 2 # BUGS
3 3  
  4 +- mesmo aluno pode entrar várias vezes em simultaneo...
  5 +- ordenar lista de alunos pelos online/offline, e depois pelo numero.
  6 +- qd scripts não são executáveis rebenta. Testar isso e dar uma mensagem de erro.
4 7 - paths manipulation in strings is unix only ('/something'). use os.path to create paths.
5 8 - alunos vêm nota final arredondada às decimas, mas é apenas um arredondamento visual. Pode acontecer o aluno chumbar, mas ver uma nota positiva (e.g. 9.46 mostra 9.5 e presume que esta aprovado). Mostrar 3 casas?
6 9 - alunos podem entrar duas vezes em simultaneo. impedir, e permitir ao docente fazer kick-out
... ...
config/server.conf
1 1 # -*- coding: utf-8 -*-
2 2  
3 3 [global]
4   -environment= 'production'
  4 +; environment= 'production'
5 5  
6 6 ; number of threads running
7 7 server.thread_pool= 10
... ...
questions.py
1 1  
2   -import yaml
3   -import random
4   -import re
5   -import subprocess
6   -import sys
7   -import os.path
8   -
9   -# Example usage:
  2 +# Example:
10 3 #
11 4 # pool = QuestionPool()
12 5 # pool.add_from_files(['file1.yaml', 'file1.yaml'])
... ... @@ -18,6 +11,31 @@ import os.path
18 11 # test[0]['answer'] = 42 # insert answer
19 12 # grade = test[0].correct() # correct answer
20 13  
  14 +
  15 +# Functions:
  16 +# create_question(q)
  17 +# q - dictionary with question in yaml format
  18 +# returns - question instance with the correct class
  19 +
  20 +
  21 +# An instance of an actual question is a Question object:
  22 +#
  23 +# Question - base class inherited by other classes
  24 +# QuestionRadio - single choice from a list of options
  25 +# QuestionCheckbox - multiple choice, equivalent to multiple true/false
  26 +# QuestionText - line of text compared to a list of acceptable answers
  27 +# QuestionTextRegex - line of text matched against a regular expression
  28 +# QuestionTextArea - corrected by an external program
  29 +# QuestionInformation - not a question, just a box with content
  30 +
  31 +import yaml
  32 +import random
  33 +import re
  34 +import subprocess
  35 +import sys
  36 +import os.path
  37 +
  38 +
21 39 # ===========================================================================
22 40 class QuestionsPool(dict):
23 41 '''This class contains base questions read from files, but which are
... ... @@ -79,10 +97,15 @@ def create_question(q):
79 97 The remaing keys depend on the type of question.
80 98 '''
81 99  
  100 + # If `q` is of a question generator type, an external program will be run
  101 + # and expected to print a valid question in yaml format to stdout. This
  102 + # output is then converted to a dictionary and `q` becomes that dict.
82 103 if q['type'] == 'generator':
83 104 q.update(question_generator(q))
84   - # at this point the generator question was replaced by an actual question
  105 + # At this point the generator question was replaced by an actual question.
85 106  
  107 + # Depending on the type of question, a different question class is
  108 + # instantiated. All these classes derive from the base class `Question`.
86 109 types = {
87 110 'radio' : QuestionRadio,
88 111 'checkbox' : QuestionCheckbox,
... ... @@ -97,25 +120,26 @@ def create_question(q):
97 120 # '' : QuestionInformation, # default
98 121 }
99 122  
100   - # create instance of given type
  123 + # Get the correct question class for the declared question type
101 124 try:
102 125 questiontype = types[q['type']]
103 126 except KeyError:
104 127 print('[ ERROR ] Unsupported question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref']))
105 128 questiontype = Question
106 129  
107   - # create question instance and return
  130 + # Create question instance and return
108 131 try:
109 132 qinstance = questiontype(q)
110 133 except:
111 134 print('[ ERROR ] Creating question "{0}" from file "{1}".'.format(q['ref'], q['filename']))
  135 + # FIXME if here, then no qinstance is defined to return...
112 136  
113 137 return qinstance
114 138  
115 139  
116 140 # ---------------------------------------------------------------------------
117 141 def question_generator(q):
118   - '''Run an external script that will generate a question in yaml format.
  142 + '''Run an external program that will generate a question in yaml format.
119 143 This function will return the yaml converted back to a dict.'''
120 144  
121 145 q['arg'] = q.get('arg', '') # send this string to stdin
... ... @@ -126,11 +150,16 @@ def question_generator(q):
126 150 except FileNotFoundError:
127 151 print('[ ERROR ] Script "{0}" of question "{2}:{1}" not found'.format(script, q['ref'], q['filename']))
128 152 sys.exit(1)
  153 + except PermissionError:
  154 + print('[ ERROR ] Script "{0}" of question "{2}:{1}" has wrong permissions. Is it executable?'.format(script, q['ref'], q['filename']))
  155 + sys.exit(1)
129 156  
130 157 try:
131 158 qyaml = p.communicate(input=q['arg'].encode('utf-8'), timeout=5)[0].decode('utf-8')
132 159 except subprocess.TimeoutExpired:
133 160 p.kill()
  161 + print('[ ERROR ] Timeout on script "{0}" of question "{2}:{1}"'.format(script, q['ref'], q['filename']))
  162 + sys.exit(1)
134 163  
135 164 return yaml.load(qyaml)
136 165  
... ... @@ -146,6 +175,11 @@ class Question(dict):
146 175 self['grade'] = 0.0
147 176 return 0.0
148 177  
  178 + def set_defaults(self, d):
  179 + 'Add k:v pairs from default dict d for nonexistent keys'
  180 + for k,v in d.items():
  181 + self.setdefault(k, v)
  182 +
149 183  
150 184 # ===========================================================================
151 185 class QuestionRadio(Question):
... ... @@ -153,9 +187,9 @@ class QuestionRadio(Question):
153 187 type (str)
154 188 text (str)
155 189 options (list of strings)
156   - shuffle (bool, default True)
  190 + shuffle (bool, default=True)
157 191 correct (list of floats)
158   - discount (bool, default True)
  192 + discount (bool, default=True)
159 193 answer (None or an actual answer)
160 194 '''
161 195  
... ... @@ -164,45 +198,32 @@ class QuestionRadio(Question):
164 198 # create key/values as given in q
165 199 super().__init__(q)
166 200  
167   - self['text'] = self.get('text', '')
  201 + # set defaults if missing
  202 + self.set_defaults({
  203 + 'text': '',
  204 + 'correct': 0,
  205 + 'shuffle': True,
  206 + 'discount': True,
  207 + 'answer': None,
  208 + })
168 209  
169   - # generate an order for the options, e.g. [0,1,2,3,4]
170 210 n = len(self['options'])
171   - perm = list(range(n))
172   -
173   - # shuffle the order, e.g. [2,1,4,0,3]
174   - if self.get('shuffle', True):
175   - self['shuffle'] = True
176   - random.shuffle(perm)
177   - else:
178   - self['shuffle'] = False
179   -
180   - # sort options in the given order
181   - options = [None] * n # will contain list with shuffled options
182   - for i, v in enumerate(self['options']):
183   - options[perm[i]] = str(v) # always convert to string
184   - self['options'] = options
185 211  
186   - # default correct option is the first one
187   - if 'correct' not in self:
188   - self['correct'] = 0
189   -
190   - # correct can be either an integer with the correct option
191   - # or a list of degrees of correction 0..1
192   - # always convert to list, e.g. [0,0,1,0,0]
  212 + # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0]
  213 + # correctness levels from 0.0 to 1.0 (no discount here!)
193 214 if isinstance(self['correct'], int):
194   - correct = [0.0] * n
195   - correct[self['correct']] = 1.0
196   - self['correct'] = correct
  215 + self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)]
197 216  
198   - # sort correct in the given order
199   - correct = [None] * n
200   - for i, v in enumerate(self['correct']):
201   - correct[perm[i]] = float(v)
202   - self['correct'] = correct
203   - self['discount'] = bool(self.get('discount', True))
204   - self['answer'] = None
  217 + if len(self['correct']) != n:
  218 + print('[ ERROR ] Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref']))
205 219  
  220 + # generate random permutation, e.g. [2,1,4,0,3]
  221 + # and apply to `options` and `correct`
  222 + if self['shuffle']:
  223 + perm = list(range(n))
  224 + random.shuffle(perm)
  225 + self['options'] = [ str(self['options'][i]) for i in perm ]
  226 + self['correct'] = [ float(self['correct'][i]) for i in perm ]
206 227  
207 228 #------------------------------------------------------------------------
208 229 # can return negative values for wrong answers
... ... @@ -237,38 +258,28 @@ class QuestionCheckbox(Question):
237 258 # create key/values as given in q
238 259 super().__init__(q)
239 260  
240   - self['text'] = self.get('text', '')
241   -
242   - # generate an order for the options, e.g. [0,1,2,3,4]
243 261 n = len(self['options'])
244   - perm = list(range(n))
245 262  
246   - # shuffle the order, e.g. [2,1,4,0,3]
247   - if self.get('shuffle', True):
248   - self['shuffle'] = True
  263 + # set defaults if missing
  264 + self.set_defaults({
  265 + 'text': '',
  266 + 'correct': [0.0] * n, # useful for questionaries
  267 + 'shuffle': True,
  268 + 'discount': True,
  269 + 'answer': None,
  270 + })
  271 +
  272 + if len(self['correct']) != n:
  273 + print('[ ERROR ] Options and correct mismatch in "{1}", file "{0}".'.format(self['filename'], self['ref']))
  274 +
  275 + # generate random permutation, e.g. [2,1,4,0,3]
  276 + # and apply to `options` and `correct`
  277 + if self['shuffle']:
  278 + perm = list(range(n))
249 279 random.shuffle(perm)
250   - else:
251   - self['shuffle'] = False
252   -
253   - # sort options in the given order
254   - options = [None] * n # will contain list with shuffled options
255   - for i, v in enumerate(self['options']):
256   - options[perm[i]] = str(v) # always convert to string
257   - self['options'] = options
258   -
259   - # default is to give zero to all options [0,0,...,0]
260   - if 'correct' not in self:
261   - self['correct'] = [0.0] * n
  280 + self['options'] = [ str(self['options'][i]) for i in perm ]
  281 + self['correct'] = [ float(self['correct'][i]) for i in perm ]
262 282  
263   - # sort correct in the given order
264   - correct = [None] * n
265   - for i, v in enumerate(self['correct']):
266   - correct[perm[i]] = float(v)
267   - self['correct'] = correct
268   -
269   - self['discount'] = bool(self.get('discount', True))
270   -
271   - self['answer'] = None
272 283  
273 284 #------------------------------------------------------------------------
274 285 # can return negative values for wrong answers
... ... @@ -279,7 +290,7 @@ class QuestionCheckbox(Question):
279 290 else:
280 291 # answered
281 292 sum_abs = sum(abs(p) for p in self['correct'])
282   - if sum_abs < 1e-6: # in case correct: [0,0,0,0,0]
  293 + if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero
283 294 self['grade'] = 0.0
284 295  
285 296 else:
... ... @@ -311,17 +322,18 @@ class QuestionText(Question):
311 322 # create key/values as given in q
312 323 super().__init__(q)
313 324  
314   - self['text'] = self.get('text', '')
  325 + self.set_defaults({
  326 + 'text': '',
  327 + 'correct': [],
  328 + 'answer': None,
  329 + })
315 330  
316 331 # make sure its always a list of possible correct answers
317 332 if not isinstance(self['correct'], list):
318 333 self['correct'] = [self['correct']]
319 334  
320   - # make sure the elements of the list are strings
321   - for i, a in enumerate(self['correct']):
322   - self['correct'][i] = str(a)
323   -
324   - self['answer'] = None
  335 + # make sure all elements of the list are strings
  336 + self['correct'] = [str(a) for a in self['correct']]
325 337  
326 338 #------------------------------------------------------------------------
327 339 # can return negative values for wrong answers
... ... @@ -349,8 +361,12 @@ class QuestionTextRegex(Question):
349 361 def __init__(self, q):
350 362 # create key/values as given in q
351 363 super().__init__(q)
352   - self['text'] = self.get('text', '')
353   - self['answer'] = None
  364 +
  365 + self.set_defaults({
  366 + 'text': '',
  367 + 'correct': '$.^', # will always return false
  368 + 'answer': None,
  369 + })
354 370  
355 371 #------------------------------------------------------------------------
356 372 # can return negative values for wrong answers
... ... @@ -379,9 +395,14 @@ class QuestionTextArea(Question):
379 395 def __init__(self, q):
380 396 # create key/values as given in q
381 397 super().__init__(q)
382   - self['text'] = self.get('text', '')
383   - self['answer'] = None
384   - self['lines'] = self.get('lines', 8)
  398 +
  399 + self.set_defaults({
  400 + 'text': '',
  401 + 'answer': None,
  402 + 'lines': 8,
  403 + })
  404 +
  405 + self['correct'] = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct'])))
385 406  
386 407 #------------------------------------------------------------------------
387 408 # can return negative values for wrong answers
... ... @@ -394,29 +415,34 @@ class QuestionTextArea(Question):
394 415  
395 416 # The correction program expects data from stdin and prints the result to stdout.
396 417 # The result should be a string that can be parsed to a float.
397   - script = os.path.abspath(os.path.normpath(os.path.join(self['path'], self['correct'])))
398 418 try:
399   - p = subprocess.Popen([script], stdout=subprocess.PIPE, stdin=subprocess.PIPE, stderr=subprocess.STDOUT)
  419 + p = subprocess.Popen([self['correct']],
  420 + stdout=subprocess.PIPE,
  421 + stdin=subprocess.PIPE,
  422 + stderr=subprocess.STDOUT)
400 423 except FileNotFoundError as e:
401   - print(' * Script "{0}" defined in question "{1}" of file "{2}" could not be found'.format(self['correct'], self['ref'], self['filename']))
  424 + print('[ ERROR ] Script "{0}" defined in question "{1}" of file "{2}" could not be found'.format(self['correct'], self['ref'], self['filename']))
  425 + raise e
  426 + except PermissionError as e:
  427 + print('[ ERROR ] Script "{0}" has wrong permissions. Is it executable?'.format(self['correct']))
402 428 raise e
403 429  
404 430 try:
405   - value = p.communicate(input=self['answer'].encode('utf-8'), timeout=5)[0].decode('utf-8') # esta a dar erro!
  431 + value = p.communicate(input=self['answer'].encode('utf-8'), timeout=5)[0].decode('utf-8')
406 432 except subprocess.TimeoutExpired:
407 433 p.kill()
408   - # p.communicate() # FIXME parece que este communicate obriga a ficar ate ao final do processo, mas nao consigo repetir o comportamento num script simples...
409 434 value = 0.0 # student gets a zero if timout occurs
410   - printf(' * Timeout in correction script')
  435 + print('[ WARNG ] Timeout in correction script "{0}"'.format(self['correct']))
411 436  
412 437 # In case the correction program returns a non float value, we assume an error
413 438 # has occurred (for instance, invalid input). We just assume its the student's
414 439 # fault and give him Zero.
415 440 try:
416 441 self['grade'] = float(value)
417   - except (ValueError):
  442 + except ValueError as e:
418 443 self['grade'] = 0.0
419   - raise Exception('Correction of question "{0}" returned nonfloat "{1}".'.format(self['ref'], value))
  444 + print('[ ERROR ] Correction of question "{0}" returned nonfloat:\n{1}\n'.format(self['ref'], value))
  445 + raise e
420 446  
421 447 return self['grade']
422 448  
... ... @@ -432,12 +458,15 @@ class QuestionInformation(Question):
432 458 def __init__(self, q):
433 459 # create key/values as given in q
434 460 super().__init__(q)
435   - self['text'] = self.get('text', '')
436   - self['points'] = 0.0 # always override the points
  461 +
  462 + self.set_defaults({
  463 + 'text': '',
  464 + })
  465 +
  466 + self['points'] = 0.0 # always override the default points of 1.0
437 467  
438 468 #------------------------------------------------------------------------
439 469 # can return negative values for wrong answers
440 470 def correct(self):
441   - self['grade'] = 1.0 # always "correct" but points should be zero!
  471 + self['grade'] = 0.0 # always "correct" but points should be zero!
442 472 return self['grade']
443   -
... ...
serve.py
... ... @@ -69,11 +69,15 @@ class Root(object):
69 69  
70 70 # --- RESULTS ------------------------------------------------------------
71 71 @cherrypy.expose
  72 + @require()
72 73 def results(self):
73 74 if self.testconf.get('practice', False):
  75 + uid = cherrypy.session.get('userid')
  76 + name = cherrypy.session.get('name')
  77 +
74 78 r = self.database.test_grades(self.testconf['ref'])
75 79 template = self.templates.get_template('/results.html')
76   - return template.render(t=self.testconf, results=r)
  80 + return template.render(t=self.testconf, results=r, name=name, uid=uid)
77 81 else:
78 82 raise cherrypy.HTTPRedirect('/')
79 83  
... ...
static/js/question_disabler.js
... ... @@ -3,7 +3,7 @@ $(document).ready(function() {
3 3 $("input.question_disabler").change(function () {
4 4 // Disables the body of the question.
5 5 // The flip switch is on the bar, and is still accessible.
6   - $(this).parent().next().slideToggle("fast");
  6 + $(this).parent().parent().next().slideToggle("fast");
7 7 // $(this).parent().parent().className = "panel panel-info";
8 8 });
9 9 $(function () {
... ...
templates/results.html
... ... @@ -33,7 +33,8 @@
33 33 </head>
34 34 <!-- ===================================================================== -->
35 35 <body>
36   -<nav class="navbar navbar-default navbar-fixed-top" role="navigation">
  36 +
  37 + <nav class="navbar navbar-default navbar-fixed-top" role="navigation">
37 38 <div class="container-fluid drop-shadow">
38 39 <div class="navbar-header">
39 40 <button type="button" class="navbar-toggle" data-toggle="collapse" data-target="#myNavbar">
... ... @@ -42,30 +43,37 @@
42 43 <span class="icon-bar"></span>
43 44 </button>
44 45 <a class="navbar-brand" href="#">
45   - <!-- <img class="brand" alt="UEvora" src="/pomba-ue.svg"/> -->
46   - UEvora
  46 + ${t['title']}
47 47 </a>
48 48 </div>
49 49  
50   - <!-- <p class="navbar-text"> ${t['title']} </p> -->
51   -
52 50 <div class="collapse navbar-collapse" id="myNavbar">
53   - <ul class="nav navbar-nav">
54   - <li><a href="/test">Teste</a></li>
55   - <li class="active"><a href="/resultados">Resultados</a></li>
56   - </ul>
57 51  
  52 + <ul class="nav navbar-nav navbar-right">
  53 + <li class="dropdown">
  54 + <a class="dropdown-toggle" data-toggle="dropdown" href="#">
  55 + <span class="glyphicon glyphicon-user" aria-hidden="true"></span>
  56 + ${name} (${uid}) <span class="caret"></span>
  57 + </a>
  58 + <ul class="dropdown-menu">
  59 + <li><a href="/test">Teste</a></li>
  60 + <li class="active"><a href="/results">Ver resultados</a></li>
  61 + <li><a href="/logout"><span class="glyphicon glyphicon-log-out" aria-hidden="true"></span> Sair</a></li>
  62 + </ul>
  63 + </li>
  64 + </ul>
58 65 </div>
59 66 </div>
60 67 </nav>
61 68  
62 69  
  70 +
63 71 <div class="container">
64 72 <div class="panel panel-default drop-shadow">
65   - <div class="panel-heading">
  73 + <!-- <div class="panel-heading">
66 74 ${t['title']}
67 75 </div>
68   - <!-- <div class="panel-body"> -->
  76 + --><!-- <div class="panel-body"> -->
69 77 % if not results:
70 78 <h4 class="text-center">Ainda não há resultados</h4>
71 79  
... ... @@ -84,7 +92,7 @@
84 92 from datetime import datetime
85 93 %>
86 94 % for r in results:
87   - <tr>
  95 + <tr class="default">
88 96 <td class="text-center">
89 97 % if loop.index == 0:
90 98 <h4>
... ... @@ -101,7 +109,7 @@
101 109 <!-- <td>${r[0]}</td> --> <!-- numero -->
102 110 <td>
103 111 % if loop.index == 0:
104   - <h4 class="text-uppercase"><img src="\crown.jpg" /> ${r[1]}</h4>
  112 + <h4 class="text-uppercase"><img src="/crown.jpg" /> ${r[1]}</h4>
105 113 % else:
106 114 ${r[1]}
107 115 % endif
... ...
templates/test.html
1 1 <!DOCTYPE html>
2 2 <html>
3 3 <head>
4   - <meta charset="UTF-8">
  4 + <meta charset="utf-8">
5 5 <meta http-equiv="X-UA-Compatible" content="IE=edge">
6 6 <meta name="viewport" content="width=device-width, initial-scale=1">
7 7 <title> ${t['title']} </title>
... ... @@ -67,22 +67,12 @@
67 67 <span class="icon-bar"></span>
68 68 </button>
69 69 <a class="navbar-brand" href="#">
70   - <!-- <img class="brand" alt="UEvora" src="/pomba-ue.svg"/> -->
71   - UEvora
  70 + ${t['title']}
72 71 </a>
73 72 </div>
74 73  
75   - <!-- <p class="navbar-text"> ${t['title']} </p> -->
76   -
77 74 <div class="collapse navbar-collapse" id="myNavbar">
78 75  
79   - % if t['practice']:
80   - <ul class="nav navbar-nav">
81   - <li class="active"><a href="/test">Teste</a></li>
82   - <li><a href="/results">Resultados</a></li>
83   - </ul>
84   - % endif
85   -
86 76 <ul class="nav navbar-nav navbar-right">
87 77 <li class="dropdown">
88 78 <a class="dropdown-toggle" data-toggle="dropdown" href="#">
... ... @@ -90,6 +80,10 @@
90 80 ${t['name']} (${t['number']}) <span class="caret"></span>
91 81 </a>
92 82 <ul class="dropdown-menu">
  83 + <li class="active"><a href="/test">Teste</a></li>
  84 + % if t['practice']:
  85 + <li><a href="/results">Ver resultados</a></li>
  86 + % endif
93 87 <li><a href="/logout"><span class="glyphicon glyphicon-log-out" aria-hidden="true"></span> Sair</a></li>
94 88 <!-- <li><a href="#">Change password</a></li> -->
95 89 </ul>
... ... @@ -102,6 +96,7 @@
102 96  
103 97 <div class="container">
104 98 <div class="row">
  99 +
105 100 <form action="/correct/" method="post" id="test">
106 101 <%!
107 102 import markdown as md
... ... @@ -135,35 +130,40 @@
135 130 % for i,q in enumerate(questions):
136 131 <div class="ui-corner-all custom-corners">
137 132 % if q['type'] == 'information':
138   - <div class="panel panel-default drop-shadow">
139   - <div class="panel-heading">
  133 + <div class="alert alert-warning drop-shadow" role="alert">
  134 + <h4>
140 135 <span class="glyphicon glyphicon-info-sign" aria-hidden="true"></span>
141   - Informação
142   - </div>
143   - <div class="panel-body">
144   - <!-- <div class="well danger drop-shadow"> -->
145   - <h3><small>${i+1}.</small> ${q['title']}</h3>
  136 + ${q['title']}
  137 + </h4>
  138 + <p>
146 139 ${pretty(q['text'])}
147   - </div>
  140 + </p>
148 141 </div>
149 142 % elif q['type'] == 'warning':
150   - <div class="alert alert-warning drop-shadow" role="alert">
151   - <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span>
152   - ${q['text']}
  143 + <div class="alert alert-danger drop-shadow" role="alert">
  144 + <h4>
  145 + <span class="glyphicon glyphicon-exclamation-sign" aria-hidden="true"></span> Atenção
  146 + </h4>
  147 + <p>
  148 + ${pretty(q['text'])}
  149 + </p>
153 150 </div>
154 151  
155 152 % else:
156 153 <div class="panel panel-primary drop-shadow">
157   - <div class="panel-heading">
158   - <input type="checkbox" class="question_disabler" data-size="mini" name="answered-${q['ref']}" id="answered-${q['ref']}" checked="">
159   - &nbsp;Classificar
  154 + <div class="panel-heading clearfix">
  155 + <h4 class="panel-title pull-left">
  156 + ${i+1}. ${q['title']}
  157 + </h4>
  158 + <div class="pull-right">
  159 + Classificar&nbsp;
  160 + <input type="checkbox" class="question_disabler" data-size="mini" name="answered-${q['ref']}" id="answered-${q['ref']}" checked="">
  161 + </div>
160 162 </div>
161 163 <div class="panel-body" id="example${i}">
162   - <h3> <small>${i+1}.</small> ${q['title']}</h3>
163   -
164   - <p class="question">
  164 + <div class="question">
165 165 ${pretty(q['text'])}
166   - </p>
  166 + </div>
167 167  
168 168 <fieldset data-role="controlgroup">
169 169 % if q['type'] == 'radio':
... ... @@ -226,6 +226,13 @@
226 226 % endif # modal
227 227 % endif # show_hints
228 228  
  229 + % if t['show_points']:
  230 + <p class="text-right">
  231 + <small>(Cotação: ${round(q['points'] / total_points * 20.0, 1)} pontos)</small>
  232 + <p>
  233 + % endif
  234 +
  235 +
229 236 % if t['practice'] and 'grade' in q:
230 237 % if q['grade'] > 0.99:
231 238 <div class="alert alert-success" role="alert">
... ... @@ -244,14 +251,9 @@
244 251 </div>
245 252 % endif
246 253 % endif
  254 + </div> <!-- panel-body -->
247 255  
248   - % if t['show_points']:
249   - <p class="text-right">
250   - <small>(Cotação: ${round(q['points'] / total_points * 20.0, 1)} pontos)</small>
251   - <p>
252   - % endif
253 256  
254   - </div> <!-- panel-body -->
255 257 % if t['debug'] or t['show_ref']:
256 258 <div class="panel-footer">
257 259  
... ...