Commit 23b962453958e2926e5f27c253d5b95857774607

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

- new 'autosubmit' option.

- show time remaining.
- fix many pylint warnings
- show type of submission
demo/demo.yaml
... ... @@ -21,7 +21,8 @@ title: Teste de demonstração (tutorial)
21 21  
22 22 # Duration in minutes.
23 23 # (0 or undefined means infinite time)
24   -duration: 60
  24 +duration: 10
  25 +autosubmit: true
25 26  
26 27 # Show points for each question, scale 0-20.
27 28 # (default: false)
... ... @@ -29,9 +30,9 @@ show_points: true
29 30  
30 31 # scale final grade to the interval [scale_min, scale_max]
31 32 # (default: scale to [0,20])
32   -scale_points: true
33 33 scale_max: 20
34 34 scale_min: 0
  35 +scale_points: true
35 36  
36 37 # ----------------------------------------------------------------------------
37 38 # Base path applied to the questions files and all the scripts
... ...
package-lock.json
... ... @@ -3,89 +3,42 @@
3 3 "lockfileVersion": 1,
4 4 "dependencies": {
5 5 "@fortawesome/fontawesome-free": {
6   - "version": "5.11.2",
7   - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz",
8   - "integrity": "sha512-XiUPoS79r1G7PcpnNtq85TJ7inJWe0v+b5oZJZKb0pGHNIV6+UiNeQWiFGmuQ0aj7GEhnD/v9iqxIsjuRKtEnQ=="
  6 + "version": "5.13.0",
  7 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz",
  8 + "integrity": "sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg=="
9 9 },
10 10 "bootstrap": {
11   - "version": "4.3.1",
12   - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.3.1.tgz",
13   - "integrity": "sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag=="
  11 + "version": "4.4.1",
  12 + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.4.1.tgz",
  13 + "integrity": "sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA=="
14 14 },
15 15 "codemirror": {
16   - "version": "5.49.2",
17   - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.2.tgz",
18   - "integrity": "sha512-dwJ2HRPHm8w51WB5YTF9J7m6Z5dtkqbU9ntMZ1dqXyFB9IpjoUFDj80ahRVEoVanfIp6pfASJbOlbWdEf8FOzQ=="
19   - },
20   - "commander": {
21   - "version": "3.0.1",
22   - "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.1.tgz",
23   - "integrity": "sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ=="
  16 + "version": "5.52.2",
  17 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.52.2.tgz",
  18 + "integrity": "sha512-WCGCixNUck2HGvY8/ZNI1jYfxPG5cRHv0VjmWuNzbtCLz8qYA5d+je4QhSSCtCaagyeOwMi/HmmPTjBgiTm2lQ=="
24 19 },
25 20 "datatables": {
26 21 "version": "1.10.18",
27 22 "resolved": "https://registry.npmjs.org/datatables/-/datatables-1.10.18.tgz",
28 23 "integrity": "sha512-ntatMgS9NN6UMpwbmO+QkYJuKlVeMA2Mi0Gu/QxyIh+dW7ZjLSDhPT2tWlzjpIWEkDYgieDzS9Nu7bdQCW0sbQ==",
29 24 "requires": {
30   - "jquery": ">=1.7"
  25 + "jquery": "3.5.0"
31 26 }
32 27 },
33   - "esm": {
34   - "version": "3.2.25",
35   - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
36   - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA=="
37   - },
38 28 "jquery": {
39   - "version": "3.4.1",
40   - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
41   - "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
  29 + "version": "3.5.0",
  30 + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.0.tgz",
  31 + "integrity": "sha512-Xb7SVYMvygPxbFMpTFQiHh1J7HClEaThguL15N/Gg37Lri/qKyhRGZYzHRyLH8Stq3Aow0LsHO2O2ci86fCrNQ=="
42 32 },
43 33 "mathjax": {
44   - "version": "3.0.0",
45   - "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.0.0.tgz",
46   - "integrity": "sha512-z4uLbDHNbs/aRuR6zCcnzwFQuMixkHCcWqgVaommfK/3cA1Ahq7OXemn+m8JwTYcBApSHgcrSbPr9sm3sZFL+A==",
47   - "requires": {
48   - "mathjax-full": "git://github.com/mathjax/MathJax-src.git"
49   - }
50   - },
51   - "mathjax-full": {
52   - "version": "git://github.com/mathjax/MathJax-src.git#0d74266e1820220d33cb6b29d4ca3575b352ac0d",
53   - "from": "git://github.com/mathjax/MathJax-src.git",
54   - "requires": {
55   - "esm": "^3.2.25",
56   - "mj-context-menu": "^0.2.0",
57   - "speech-rule-engine": "^3.0.0-beta.6"
58   - }
59   - },
60   - "mj-context-menu": {
61   - "version": "0.2.0",
62   - "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.2.0.tgz",
63   - "integrity": "sha512-yJxrWBHCjFZEHsZgfs7m5g9OSCNzsVYadW6f6lX3pgZL67vmodtSW/4zhsYmuDKweXfHs0M1kJge1uQIasWA+g=="
  34 + "version": "3.0.5",
  35 + "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.0.5.tgz",
  36 + "integrity": "sha512-9M7VulhltkD8sIebWutK/VfAD+m+6BIFqfpjDh9Pz/etoKUtjO6UMnOhUcDmNl6iApE8C9xrUmaMyNZkZAlrMw=="
64 37 },
65 38 "popper.js": {
66   - "version": "1.16.0",
67   - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.0.tgz",
68   - "integrity": "sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw=="
69   - },
70   - "speech-rule-engine": {
71   - "version": "3.0.0-beta.6",
72   - "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-3.0.0-beta.6.tgz",
73   - "integrity": "sha512-B7gcT53jAsKpx7WvFYQcyUlFmgS3Wa9KlDy0FY8SOTa+Wz5EqmI0MpCD5/fYm8/2qiCPp8HwZg+H3cBgM+sNVw==",
74   - "requires": {
75   - "commander": "*",
76   - "wicked-good-xpath": "*",
77   - "xmldom-sre": "^0.1.31"
78   - }
79   - },
80   - "wicked-good-xpath": {
81   - "version": "1.3.0",
82   - "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz",
83   - "integrity": "sha1-gbDpXoZQ5JyUsiKY//hoa1VTz2w="
84   - },
85   - "xmldom-sre": {
86   - "version": "0.1.31",
87   - "resolved": "https://registry.npmjs.org/xmldom-sre/-/xmldom-sre-0.1.31.tgz",
88   - "integrity": "sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw=="
  39 + "version": "1.16.1",
  40 + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
  41 + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ=="
89 42 }
90 43 }
91 44 }
... ...
package.json
... ... @@ -2,12 +2,12 @@
2 2 "description": "Javascript libraries required to run the server",
3 3 "email": "mjsb@uevora.pt",
4 4 "dependencies": {
5   - "@fortawesome/fontawesome-free": "^5.11.2",
6   - "bootstrap": "^4.3",
7   - "codemirror": "^5.49.2",
  5 + "@fortawesome/fontawesome-free": "^5.13.0",
  6 + "bootstrap": "^4.4.1",
  7 + "codemirror": "^5.52.2",
8 8 "datatables": "^1.10",
9   - "jquery": "^3.4.1",
10   - "mathjax": "^3",
11   - "popper.js": "^1.16.0"
  9 + "jquery": "^3.5.0",
  10 + "mathjax": "^3.0.5",
  11 + "popper.js": "^1.16.1"
12 12 }
13 13 }
... ...
perguntations/__init__.py
... ... @@ -32,7 +32,7 @@ proof of submission and for review.
32 32 '''
33 33  
34 34 APP_NAME = 'perguntations'
35   -APP_VERSION = '2020.03.dev1'
  35 +APP_VERSION = '2020.04.dev1'
36 36 APP_DESCRIPTION = __doc__
37 37  
38 38 __author__ = 'Miguel Barão'
... ...
perguntations/main.py
... ... @@ -18,12 +18,15 @@ from . import APP_NAME, APP_VERSION
18 18  
19 19 # ----------------------------------------------------------------------------
20 20 def parse_cmdline_arguments():
  21 + '''
  22 + Get command line arguments
  23 + '''
21 24 parser = argparse.ArgumentParser(
22 25 description='Server for online tests. Enrolled students and tests '
23 26 'have to be previously configured. Please read the documentation '
24 27 'included with this software before running the server.')
25 28 parser.add_argument('testfile',
26   - type=str, nargs='?', # FIXME only one test supported
  29 + type=str, nargs='?',
27 30 help='tests in YAML format')
28 31 parser.add_argument('--allow-all',
29 32 action='store_true',
... ... @@ -47,6 +50,10 @@ def parse_cmdline_arguments():
47 50  
48 51 # ----------------------------------------------------------------------------
49 52 def get_logger_config(debug=False):
  53 + '''
  54 + Load logger configuration from ~/.config directory if exists,
  55 + otherwise set default paramenters.
  56 + '''
50 57 if debug:
51 58 filename = 'logger-debug.yaml'
52 59 level = 'DEBUG'
... ... @@ -92,9 +99,10 @@ def get_logger_config(debug=False):
92 99  
93 100  
94 101 # ----------------------------------------------------------------------------
95   -# Tornado web server
96   -# ----------------------------------------------------------------------------
97 102 def main():
  103 + '''
  104 + Tornado web server
  105 + '''
98 106 args = parse_cmdline_arguments()
99 107  
100 108 if args.version:
... ... @@ -127,16 +135,17 @@ def main():
127 135 else:
128 136 certs_dir = path.expanduser('~/.local/share/certs')
129 137  
130   - ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
  138 + ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
131 139 try:
132   - ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
  140 + ssl_opt.load_cert_chain(path.join(certs_dir, 'cert.pem'),
133 141 path.join(certs_dir, 'privkey.pem'))
134 142 except FileNotFoundError:
135   - logging.critical(f'SSL certificates missing in {certs_dir}')
  143 + logging.critical('SSL certificates missing in %s', certs_dir)
136 144 sys.exit(-1)
137 145  
138 146 # --- run webserver ----------------------------------------------------
139   - run_webserver(app=testapp, ssl=ssl_ctx, port=args.port, debug=args.debug)
  147 + run_webserver(app=testapp, ssl_opt=ssl_opt, port=args.port,
  148 + debug=args.debug)
140 149  
141 150  
142 151 # ----------------------------------------------------------------------------
... ...
perguntations/serve.py
1 1 #!/usr/bin/env python3
2 2  
  3 +'''
  4 +Handles the web and html part of the application interface.
  5 +The tornadoweb framework is used.
  6 +'''
  7 +
  8 +
3 9 # python standard library
4 10 from os import path
5 11 import sys
6 12 import base64
7 13 import uuid
8 14 import logging.config
9   -import argparse
  15 +# import argparse
10 16 import mimetypes
11 17 import signal
12 18 import functools
13 19 import json
14   -import ssl
  20 +# import ssl
15 21  
16 22 # user installed libraries
17 23 import tornado.ioloop
... ... @@ -29,15 +35,15 @@ from perguntations.parser_markdown import md_to_html
29 35 class WebApplication(tornado.web.Application):
30 36 def __init__(self, testapp, debug=False):
31 37 handlers = [
32   - (r'/login', LoginHandler),
33   - (r'/logout', LogoutHandler),
34   - (r'/test', TestHandler),
35   - (r'/review', ReviewHandler),
36   - (r'/admin', AdminHandler),
37   - (r'/file', FileHandler),
  38 + (r'/login', LoginHandler),
  39 + (r'/logout', LogoutHandler),
  40 + (r'/test', TestHandler),
  41 + (r'/review', ReviewHandler),
  42 + (r'/admin', AdminHandler),
  43 + (r'/file', FileHandler),
38 44 # (r'/root', MainHandler), # FIXME
39 45 # (r'/ws', AdminSocketHandler),
40   - (r'/', RootHandler), # TODO multiple tests
  46 + (r'/', RootHandler),
41 47 ]
42 48  
43 49 settings = {
... ... @@ -54,15 +60,15 @@ class WebApplication(tornado.web.Application):
54 60  
55 61  
56 62 # ----------------------------------------------------------------------------
57   -# Decorator used to restrict access to the administrator
58   -# ----------------------------------------------------------------------------
59 63 def admin_only(func):
  64 + '''
  65 + Decorator used to restrict access to the administrator
  66 + '''
60 67 @functools.wraps(func)
61 68 async def wrapper(self, *args, **kwargs):
62 69 if self.current_user != '0':
63 70 raise tornado.web.HTTPError(403) # forbidden
64   - else:
65   - await func(self, *args, **kwargs)
  71 + await func(self, *args, **kwargs)
66 72 return wrapper
67 73  
68 74  
... ... @@ -72,12 +78,20 @@ def admin_only(func):
72 78 class BaseHandler(tornado.web.RequestHandler):
73 79 @property
74 80 def testapp(self):
  81 + '''
  82 + simplifies access to the application
  83 + '''
75 84 return self.application.testapp
76 85  
77 86 def get_current_user(self):
  87 + '''
  88 + HTML is stateless, so a cookie is used to identify the user.
  89 + This function returns the cookie for the current user.
  90 + '''
78 91 cookie = self.get_secure_cookie('user')
79 92 if cookie:
80 93 return cookie.decode('utf-8')
  94 + return None
81 95  
82 96  
83 97 # ----------------------------------------------------------------------------
... ... @@ -145,12 +159,15 @@ class AdminHandler(BaseHandler):
145 159 @tornado.web.authenticated
146 160 @admin_only
147 161 async def get(self):
  162 + '''
  163 + Admin page.
  164 + '''
148 165 cmd = self.get_query_argument('cmd', default=None)
149 166  
150 167 if cmd == 'students_table':
151 168 data = {'data': self.testapp.get_students_state()}
152 169 self.write(json.dumps(data, default=str))
153   - elif cmd == 'test': # FIXME which test?
  170 + elif cmd == 'test':
154 171 data = {
155 172 'data': {
156 173 'title': self.testapp.testfactory['title'],
... ... @@ -167,6 +184,9 @@ class AdminHandler(BaseHandler):
167 184 @tornado.web.authenticated
168 185 @admin_only
169 186 async def post(self):
  187 + '''
  188 + Executes commands from the admin page.
  189 + '''
170 190 cmd = self.get_body_argument('cmd', None)
171 191 value = self.get_body_argument('value', None)
172 192  
... ... @@ -180,8 +200,9 @@ class AdminHandler(BaseHandler):
180 200 await self.testapp.update_student_password(uid=value, pw='')
181 201  
182 202 elif cmd == 'insert_student':
183   - s = json.loads(value)
184   - self.testapp.insert_new_student(uid=s['number'], name=s['name'])
  203 + student = json.loads(value)
  204 + self.testapp.insert_new_student(uid=student['number'],
  205 + name=student['name'])
185 206  
186 207 else:
187 208 logging.error(f'Unknown command: "{cmd}"')
... ... @@ -192,12 +213,19 @@ class AdminHandler(BaseHandler):
192 213 # ----------------------------------------------------------------------------
193 214 class LoginHandler(BaseHandler):
194 215 def get(self):
  216 + '''
  217 + Render login page.
  218 + '''
195 219 self.render('login.html', error='')
196 220  
197 221 async def post(self):
  222 + '''
  223 + Authenticates student (prefix 'l' are removed) and login.
  224 + '''
  225 +
198 226 uid = self.get_body_argument('uid').lstrip('l')
199   - pw = self.get_body_argument('pw')
200   - login_ok = await self.testapp.login(uid, pw)
  227 + password = self.get_body_argument('pw')
  228 + login_ok = await self.testapp.login(uid, password)
201 229  
202 230 if login_ok:
203 231 self.set_secure_cookie("user", str(uid), expires_days=30)
... ... @@ -212,6 +240,9 @@ class LoginHandler(BaseHandler):
212 240 class LogoutHandler(BaseHandler):
213 241 @tornado.web.authenticated
214 242 def get(self):
  243 + '''
  244 + Logs out a user.
  245 + '''
215 246 self.clear_cookie('user')
216 247 self.redirect('/')
217 248  
... ... @@ -223,8 +254,12 @@ class LogoutHandler(BaseHandler):
223 254 # handles root / to redirect students to /test and admininistrator to /admin
224 255 # ----------------------------------------------------------------------------
225 256 class RootHandler(BaseHandler):
  257 +
226 258 @tornado.web.authenticated
227 259 def get(self):
  260 + '''
  261 + Redirects students to the /test and admin to the /admin page.
  262 + '''
228 263 if self.current_user == '0':
229 264 self.redirect('/admin')
230 265 else:
... ... @@ -235,28 +270,38 @@ class RootHandler(BaseHandler):
235 270 # Serves files from the /public subdir of the topics.
236 271 # ----------------------------------------------------------------------------
237 272 class FileHandler(BaseHandler):
  273 + '''
  274 + Handles static files from questions like images, etc.
  275 + '''
  276 +
  277 +
238 278 @tornado.web.authenticated
239 279 async def get(self):
  280 + '''
  281 + Returns requested file. Files are obtained from the 'public' directory
  282 + of each question.
  283 + '''
  284 +
240 285 uid = self.current_user
241 286 ref = self.get_query_argument('ref', None)
242 287 image = self.get_query_argument('image', None)
243 288 content_type = mimetypes.guess_type(image)[0]
244 289  
245 290 if uid != '0':
246   - t = self.testapp.get_student_test(uid)
  291 + test = self.testapp.get_student_test(uid)
247 292 else:
248 293 logging.error('FIXME Cannot serve images for review.')
249 294 raise tornado.web.HTTPError(404) # FIXME admin
250 295  
251   - if t is None:
  296 + if test is None:
252 297 raise tornado.web.HTTPError(404) # Not Found
253 298  
254   - for q in t['questions']:
  299 + for question in test['questions']:
255 300 # search for the question that contains the image
256   - if q['ref'] == ref:
257   - filepath = path.join(q['path'], 'public', image)
  301 + if question['ref'] == ref:
  302 + filepath = path.join(question['path'], 'public', image)
258 303 try:
259   - f = open(filepath, 'rb')
  304 + file = open(filepath, 'rb')
260 305 except FileNotFoundError:
261 306 logging.error(f'File not found: {filepath}')
262 307 except PermissionError:
... ... @@ -264,18 +309,23 @@ class FileHandler(BaseHandler):
264 309 except OSError:
265 310 logging.error(f'Error opening file: {filepath}')
266 311 else:
267   - data = f.read()
268   - f.close()
  312 + data = file.read()
  313 + file.close()
269 314 self.set_header("Content-Type", content_type)
270 315 self.write(data)
271 316 await self.flush()
272   - break # for loop
  317 + break
273 318  
274 319  
275 320 # ----------------------------------------------------------------------------
276 321 # Test shown to students
277 322 # ----------------------------------------------------------------------------
278 323 class TestHandler(BaseHandler):
  324 + '''
  325 + Generates test to student.
  326 + Receives answers, corrects the test and sends back the grade.
  327 + '''
  328 +
279 329 _templates = {
280 330 'radio': 'question-radio.html',
281 331 'checkbox': 'question-checkbox.html',
... ... @@ -293,35 +343,43 @@ class TestHandler(BaseHandler):
293 343 # --- GET
294 344 @tornado.web.authenticated
295 345 async def get(self):
  346 + '''
  347 + Generates test and sends to student
  348 + '''
296 349 uid = self.current_user
297   - t = self.testapp.get_student_test(uid) # reloading returns same test
298   - if t is None:
299   - t = await self.testapp.generate_test(uid)
300   - self.render('test.html', t=t, md=md_to_html, templ=self._templates)
  350 + test = self.testapp.get_student_test(uid) # reloading returns same test
  351 + if test is None:
  352 + test = await self.testapp.generate_test(uid)
  353 + self.render('test.html', t=test, md=md_to_html, templ=self._templates)
301 354  
302 355 # --- POST
303 356 @tornado.web.authenticated
304 357 async def post(self):
305   - uid = self.current_user
  358 + '''
  359 + Receives answers, fixes some html weirdness, corrects test and
  360 + sends back the grade.
306 361  
307   - # self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']}
308   - # build dictionary ans={0: 'answer0', 1:, 'answer1', ...}
309   - # unanswered questions not included.
310   - t = self.testapp.get_student_test(uid)
  362 + self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']}
  363 + builds dictionary ans={0: 'answer0', 1:, 'answer1', ...}
  364 + unanswered questions not included.
  365 + '''
  366 +
  367 + uid = self.current_user
  368 + test = self.testapp.get_student_test(uid)
311 369 ans = {}
312   - for i, q in enumerate(t['questions']):
  370 + for i, question in enumerate(test['questions']):
313 371 qid = str(i)
314 372 if 'answered-' + qid in self.request.arguments:
315 373 ans[i] = self.get_body_arguments(qid)
316 374  
317 375 # remove enclosing list in some question types
318   - if q['type'] == 'radio':
  376 + if question['type'] == 'radio':
319 377 if not ans[i]:
320 378 ans[i] = None
321 379 else:
322 380 ans[i] = ans[i][0]
323   - elif q['type'] in ('text', 'text-regex', 'textarea',
324   - 'numeric-interval'):
  381 + elif question['type'] in ('text', 'text-regex', 'textarea',
  382 + 'numeric-interval'):
325 383 ans[i] = ans[i][0]
326 384  
327 385 # correct answered questions and logout
... ... @@ -331,7 +389,7 @@ class TestHandler(BaseHandler):
331 389  
332 390 # show final grade and grades of other tests in the database
333 391 allgrades = self.testapp.get_student_grades_from_all_tests(uid)
334   - self.render('grade.html', t=t, allgrades=allgrades)
  392 + self.render('grade.html', t=test, allgrades=allgrades)
335 393  
336 394  
337 395 # ----------------------------------------------------------------------------
... ... @@ -368,6 +426,9 @@ class ReviewHandler(BaseHandler):
368 426 @tornado.web.authenticated
369 427 @admin_only
370 428 async def get(self):
  429 + '''
  430 + Opens JSON file with a given corrected test and renders it
  431 + '''
371 432 test_id = self.get_query_argument('test_id', None)
372 433 logging.info(f'Review test {test_id}.')
373 434 fname = self.testapp.get_json_filename_of_test(test_id)
... ... @@ -376,28 +437,35 @@ class ReviewHandler(BaseHandler):
376 437 raise tornado.web.HTTPError(404) # Not Found
377 438  
378 439 try:
379   - f = open(path.expanduser(fname))
  440 + jsonfile = open(path.expanduser(fname))
380 441 except OSError:
381 442 logging.error(f'Cannot open "{fname}" for review.')
382 443 else:
383   - with f:
384   - t = json.load(f)
385   - self.render('review.html', t=t, md=md_to_html,
  444 + with jsonfile:
  445 + test = json.load(jsonfile)
  446 + self.render('review.html', t=test, md=md_to_html,
386 447 templ=self._templates)
387 448  
388 449  
389 450  
390 451 # ----------------------------------------------------------------------------
391   -def signal_handler(signal, frame):
392   - r = input(' --> Stop webserver? (yes/no) ')
393   - if r.lower() == 'yes':
  452 +def signal_handler(sig, frame):
  453 + '''
  454 + Catches Ctrl-C and stops webserver
  455 + '''
  456 + reply = input(' --> Stop webserver? (yes/no) ')
  457 + if reply.lower() == 'yes':
394 458 tornado.ioloop.IOLoop.current().stop()
395 459 logging.critical('Webserver stopped.')
396 460 sys.exit(0)
397 461  
398 462 # ----------------------------------------------------------------------------
399   -def run_webserver(app, ssl, port, debug):
400   - # --- create web application ---------------------------------------------
  463 +def run_webserver(app, ssl_opt, port, debug):
  464 + '''
  465 + Starts and runs webserver until a SIGINT signal (Ctrl-C) is received.
  466 + '''
  467 +
  468 + # --- create web application
401 469 logging.info('Starting WebApplication (tornado)')
402 470 try:
403 471 webapp = WebApplication(app, debug=debug)
... ... @@ -406,7 +474,7 @@ def run_webserver(app, ssl, port, debug):
406 474 raise
407 475  
408 476 try:
409   - httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl)
  477 + httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt)
410 478 except ValueError:
411 479 logging.critical('Certificates cert.pem, privkey.pem not found')
412 480 sys.exit(1)
... ... @@ -414,7 +482,7 @@ def run_webserver(app, ssl, port, debug):
414 482 try:
415 483 httpserver.listen(port)
416 484 except OSError:
417   - logger.critical(f'Cannot bind port {port}. Already in use?')
  485 + logging.critical(f'Cannot bind port {port}. Already in use?')
418 486 sys.exit(1)
419 487  
420 488 logging.info(f'Webserver listening on {port}... (Ctrl-C to stop)')
... ...
perguntations/static/css/test.css
... ... @@ -5,7 +5,7 @@ html {
5 5  
6 6 body {
7 7 padding-top: 100px; /* make room at top of page for the navbar */
8   - background: #aaa;
  8 + background: #bbb;
9 9 }
10 10  
11 11 /* Hack to avoid name clash between pygments and mathjax */
... ...
perguntations/templates/test.html
... ... @@ -38,13 +38,13 @@
38 38 <!-- My scripts -->
39 39 <script defer src="/static/js/question_disabler.js"></script>
40 40 <script defer src="/static/js/prevent_enter_submit.js"></script>
41   - <script defer src="/static/js/clock.js"></script>
  41 +
42 42 </head>
43 43 <!-- ===================================================================== -->
44   -<body>
  44 +<body id="test">
45 45 <!-- ===================================================================== -->
46 46  
47   -<nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-dark">
  47 +<nav id="navbar" class="navbar navbar-expand-sm fixed-top navbar-dark bg-dark">
48 48 <a class="navbar-brand" href="#">
49 49 <img src="/static/logo_horizontal.png" height="30" alt="">
50 50 </a>
... ... @@ -91,7 +91,11 @@
91 91 </div>
92 92 <div class="row">
93 93 <label for="duracao" class="col-sm-3">Duração:</label>
94   - <div class="col-sm-9" id="duracao">{{ str(t['duration'])+' minutos' if t['duration'] > 0 else chr(8734) }}</div>
  94 + <div class="col-sm-9" id="duracao">{{ str(t['duration'])+' minutos' if t['duration'] > 0 else 'sem limite' }}</div>
  95 + </div>
  96 + <div class="row">
  97 + <label for="submissao" class="col-sm-3">Submissão:</label>
  98 + <div class="col-sm-9" id="submissao">{{ 'automática' if t['autosubmit'] else 'manual' }}</div>
95 99 </div>
96 100 </h5>
97 101 </div>
... ... @@ -146,5 +150,44 @@
146 150 });
147 151 </script>
148 152  
  153 +<script>
  154 + var finishtime = new Date().getTime() + {{ t['duration']*60*1000 }};
  155 +
  156 + {% if t['duration'] == 0 %}
  157 + $("#clock").html("+\u221e");
  158 + {% else %}
  159 +
  160 +
  161 +
  162 + // Update the count down every 1 second
  163 + var x = setInterval(function() {
  164 + var now = new Date().getTime();
  165 + var distance = finishtime - now;
  166 +
  167 + // Time calculations for days, hours, minutes and seconds
  168 + var minutes = Math.floor((distance / (1000 * 60)));
  169 + var seconds = Math.floor((distance % (1000 * 60)) / 1000);
  170 +
  171 + if (distance >= 1000*60) {
  172 + $("#clock").html(minutes + ":" + (seconds<10?'0':'') +seconds);
  173 + }
  174 + else if (distance >= 0) {
  175 + $("#navbar").removeClass('bg-dark').addClass("bg-danger");
  176 + $("#clock").html(seconds);
  177 + }
  178 + else {
  179 + $("#clock").html(0);
  180 + {% if t['autosubmit'] %}
  181 + $("#test").submit();
  182 + {% end %}
  183 + }
  184 + }, 1000);
  185 +
  186 + {% end %}
  187 +
  188 +</script>
  189 +
  190 +
  191 +
149 192 </body>
150 193 </html>
... ...
perguntations/test.py
1 1  
  2 +
2 3 # python standard library
3 4 from os import path
4 5 import random
... ... @@ -19,17 +20,22 @@ class TestFactoryException(Exception):
19 20  
20 21  
21 22 # ============================================================================
22   -# Each instance of TestFactory() is a test generator.
23   -# For example, if we want to serve two different tests, then we need two
24   -# instances of TestFactory(), one for each test.
25   -# ============================================================================
26 23 class TestFactory(dict):
27   - # ------------------------------------------------------------------------
28   - # Loads configuration from yaml file, then overrides some configurations
29   - # using the conf argument.
30   - # Base questions are added to a pool of questions factories.
  24 + '''
  25 + Each instance of TestFactory() is a test generator.
  26 + For example, if we want to serve two different tests, then we need two
  27 + instances of TestFactory(), one for each test.
  28 + '''
  29 +
  30 +
31 31 # ------------------------------------------------------------------------
32 32 def __init__(self, conf):
  33 + '''
  34 + Loads configuration from yaml file, then overrides some configurations
  35 + using the conf argument.
  36 + Base questions are added to a pool of questions factories.
  37 + '''
  38 +
33 39 # --- set test defaults and then use given configuration
34 40 super().__init__({ # defaults
35 41 'title': '',
... ... @@ -38,6 +44,7 @@ class TestFactory(dict):
38 44 'scale_max': 20.0,
39 45 'scale_min': 0.0,
40 46 'duration': 0, # 0=infinite
  47 + 'autosubmit': False,
41 48 'debug': False,
42 49 'show_ref': False
43 50 })
... ... @@ -49,8 +56,8 @@ class TestFactory(dict):
49 56  
50 57 # --- find refs of all questions used in the test
51 58 qrefs = {r for qq in self['questions'] for r in qq['ref']}
52   - logger.info(f'Declared {len(qrefs)} questions '
53   - f'(each test uses {len(self["questions"])}).')
  59 + logger.info('Declared %d questions (each test uses %d).',
  60 + len(qrefs), len(self["questions"]))
54 61  
55 62 # --- for review, we are done. no factories needed
56 63 if self['review']:
... ... @@ -60,64 +67,66 @@ class TestFactory(dict):
60 67 # --- load and build question factories
61 68 self.question_factory = {}
62 69  
63   - n = 1
  70 + counter = 1
64 71 for file in self["files"]:
65 72 fullpath = path.normpath(path.join(self["questions_dir"], file))
66 73 (dirname, filename) = path.split(fullpath)
67 74  
68   - logger.info(f'Loading "{fullpath}"...')
  75 + logger.info('Loading "%s"...', fullpath)
69 76 questions = load_yaml(fullpath) # , default=[])
70 77  
71   - for i, q in enumerate(questions):
  78 + for i, question in enumerate(questions):
72 79 # make sure every question in the file is a dictionary
73   - if not isinstance(q, dict):
  80 + if not isinstance(question, dict):
74 81 msg = f'Question {i} in {file} is not a dictionary'
75 82 raise TestFactoryException(msg)
76 83  
77 84 # check if ref is missing, then set to '/path/file.yaml:3'
78   - if 'ref' not in q:
79   - q['ref'] = f'{file}:{i:04}'
80   - logger.warning(f'Missing "ref" set to "{q["ref"]}"')
  85 + if 'ref' not in question:
  86 + question['ref'] = f'{file}:{i:04}'
  87 + logger.warning('Missing ref set to "%s"', question["ref"])
81 88  
82 89 # check for duplicate refs
83   - if q['ref'] in self.question_factory:
84   - other = self.question_factory[q['ref']]
  90 + if question['ref'] in self.question_factory:
  91 + other = self.question_factory[question['ref']]
85 92 otherfile = path.join(other.question['path'],
86 93 other.question['filename'])
87   - msg = (f'Duplicate reference "{q["ref"]}" in files '
  94 + msg = (f'Duplicate reference "{question["ref"]}" in files '
88 95 f'"{otherfile}" and "{fullpath}".')
89 96 raise TestFactoryException(msg)
90 97  
91 98 # make factory only for the questions used in the test
92   - if q['ref'] in qrefs:
93   - q.setdefault('type', 'information')
94   - q.update({
  99 + if question['ref'] in qrefs:
  100 + question.setdefault('type', 'information')
  101 + question.update({
95 102 'filename': filename,
96 103 'path': dirname,
97 104 'index': i # position in the file, 0 based
98 105 })
99 106  
100   - self.question_factory[q['ref']] = QFactory(q)
  107 + self.question_factory[question['ref']] = QFactory(question)
101 108  
102 109 # check if all the questions can be correctly generated
103 110 try:
104   - self.question_factory[q['ref']].generate()
  111 + self.question_factory[question['ref']].generate()
105 112 except Exception:
106   - msg = f'Failed to generate "{q["ref"]}"'
  113 + msg = f'Failed to generate "{question["ref"]}"'
107 114 raise TestFactoryException(msg)
108 115 else:
109   - logger.info(f'{n:4}. "{q["ref"]}" Ok.')
110   - n += 1
  116 + logger.info('%4d. "%s" Ok.', counter, question["ref"])
  117 + counter += 1
111 118  
112 119 qmissing = qrefs.difference(set(self.question_factory.keys()))
113 120 if qmissing:
114 121 raise TestFactoryException(f'Could not find questions {qmissing}.')
115 122  
116 123 # ------------------------------------------------------------------------
117   - # Checks for valid keys and sets default values.
118   - # Also checks if some files and directories exist
119   - # ------------------------------------------------------------------------
120 124 def sanity_checks(self):
  125 + '''
  126 + Checks for valid keys and sets default values.
  127 + Also checks if some files and directories exist
  128 + '''
  129 +
121 130 # --- ref
122 131 if 'ref' not in self:
123 132 raise TestFactoryException('Missing "ref" in configuration!')
... ... @@ -125,7 +134,7 @@ class TestFactory(dict):
125 134 # --- check database
126 135 if 'database' not in self:
127 136 raise TestFactoryException('Missing "database" in configuration')
128   - elif not path.isfile(path.expanduser(self['database'])):
  137 + if not path.isfile(path.expanduser(self['database'])):
129 138 msg = f'Database "{self["database"]}" not found!'
130 139 raise TestFactoryException(msg)
131 140  
... ... @@ -137,8 +146,8 @@ class TestFactory(dict):
137 146 # --- check if answers_dir is a writable directory
138 147 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
139 148 try:
140   - with open(testfile, 'w') as f:
141   - f.write('You can safely remove this file.')
  149 + with open(testfile, 'w') as file:
  150 + file.write('You can safely remove this file.')
142 151 except OSError:
143 152 msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
144 153 raise TestFactoryException(msg)
... ... @@ -149,7 +158,7 @@ class TestFactory(dict):
149 158  
150 159 if self['scale_points']:
151 160 smin, smax = self["scale_min"], self["scale_max"]
152   - logger.info(f'Grades will be scaled to [{smin}, {smax}]')
  161 + logger.info('Grades will be scaled to [%g, %g]', smin, smax)
153 162 else:
154 163 logger.info('Grades are just the sum of points defined for the '
155 164 'questions, not being scaled.')
... ... @@ -240,6 +249,7 @@ class TestFactory(dict):
240 249 'questions': test, # list of Question instances
241 250 'answers_dir': self['answers_dir'],
242 251 'duration': self['duration'],
  252 + 'autosubmit': self['autosubmit'],
243 253 'scale_min': self['scale_min'],
244 254 'scale_max': self['scale_max'],
245 255 'show_points': self['show_points'],
... ...