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,7 +21,8 @@ title: Teste de demonstração (tutorial)
21 21
22 # Duration in minutes. 22 # Duration in minutes.
23 # (0 or undefined means infinite time) 23 # (0 or undefined means infinite time)
24 -duration: 60 24 +duration: 10
  25 +autosubmit: true
25 26
26 # Show points for each question, scale 0-20. 27 # Show points for each question, scale 0-20.
27 # (default: false) 28 # (default: false)
@@ -29,9 +30,9 @@ show_points: true @@ -29,9 +30,9 @@ show_points: true
29 30
30 # scale final grade to the interval [scale_min, scale_max] 31 # scale final grade to the interval [scale_min, scale_max]
31 # (default: scale to [0,20]) 32 # (default: scale to [0,20])
32 -scale_points: true  
33 scale_max: 20 33 scale_max: 20
34 scale_min: 0 34 scale_min: 0
  35 +scale_points: true
35 36
36 # ---------------------------------------------------------------------------- 37 # ----------------------------------------------------------------------------
37 # Base path applied to the questions files and all the scripts 38 # Base path applied to the questions files and all the scripts
package-lock.json
@@ -3,89 +3,42 @@ @@ -3,89 +3,42 @@
3 "lockfileVersion": 1, 3 "lockfileVersion": 1,
4 "dependencies": { 4 "dependencies": {
5 "@fortawesome/fontawesome-free": { 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 "bootstrap": { 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 "codemirror": { 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 "datatables": { 20 "datatables": {
26 "version": "1.10.18", 21 "version": "1.10.18",
27 "resolved": "https://registry.npmjs.org/datatables/-/datatables-1.10.18.tgz", 22 "resolved": "https://registry.npmjs.org/datatables/-/datatables-1.10.18.tgz",
28 "integrity": "sha512-ntatMgS9NN6UMpwbmO+QkYJuKlVeMA2Mi0Gu/QxyIh+dW7ZjLSDhPT2tWlzjpIWEkDYgieDzS9Nu7bdQCW0sbQ==", 23 "integrity": "sha512-ntatMgS9NN6UMpwbmO+QkYJuKlVeMA2Mi0Gu/QxyIh+dW7ZjLSDhPT2tWlzjpIWEkDYgieDzS9Nu7bdQCW0sbQ==",
29 "requires": { 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 "jquery": { 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 "mathjax": { 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 "popper.js": { 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 }
@@ -2,12 +2,12 @@ @@ -2,12 +2,12 @@
2 "description": "Javascript libraries required to run the server", 2 "description": "Javascript libraries required to run the server",
3 "email": "mjsb@uevora.pt", 3 "email": "mjsb@uevora.pt",
4 "dependencies": { 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 "datatables": "^1.10", 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,7 +32,7 @@ proof of submission and for review.
32 ''' 32 '''
33 33
34 APP_NAME = 'perguntations' 34 APP_NAME = 'perguntations'
35 -APP_VERSION = '2020.03.dev1' 35 +APP_VERSION = '2020.04.dev1'
36 APP_DESCRIPTION = __doc__ 36 APP_DESCRIPTION = __doc__
37 37
38 __author__ = 'Miguel Barão' 38 __author__ = 'Miguel Barão'
perguntations/main.py
@@ -18,12 +18,15 @@ from . import APP_NAME, APP_VERSION @@ -18,12 +18,15 @@ from . import APP_NAME, APP_VERSION
18 18
19 # ---------------------------------------------------------------------------- 19 # ----------------------------------------------------------------------------
20 def parse_cmdline_arguments(): 20 def parse_cmdline_arguments():
  21 + '''
  22 + Get command line arguments
  23 + '''
21 parser = argparse.ArgumentParser( 24 parser = argparse.ArgumentParser(
22 description='Server for online tests. Enrolled students and tests ' 25 description='Server for online tests. Enrolled students and tests '
23 'have to be previously configured. Please read the documentation ' 26 'have to be previously configured. Please read the documentation '
24 'included with this software before running the server.') 27 'included with this software before running the server.')
25 parser.add_argument('testfile', 28 parser.add_argument('testfile',
26 - type=str, nargs='?', # FIXME only one test supported 29 + type=str, nargs='?',
27 help='tests in YAML format') 30 help='tests in YAML format')
28 parser.add_argument('--allow-all', 31 parser.add_argument('--allow-all',
29 action='store_true', 32 action='store_true',
@@ -47,6 +50,10 @@ def parse_cmdline_arguments(): @@ -47,6 +50,10 @@ def parse_cmdline_arguments():
47 50
48 # ---------------------------------------------------------------------------- 51 # ----------------------------------------------------------------------------
49 def get_logger_config(debug=False): 52 def get_logger_config(debug=False):
  53 + '''
  54 + Load logger configuration from ~/.config directory if exists,
  55 + otherwise set default paramenters.
  56 + '''
50 if debug: 57 if debug:
51 filename = 'logger-debug.yaml' 58 filename = 'logger-debug.yaml'
52 level = 'DEBUG' 59 level = 'DEBUG'
@@ -92,9 +99,10 @@ def get_logger_config(debug=False): @@ -92,9 +99,10 @@ def get_logger_config(debug=False):
92 99
93 100
94 # ---------------------------------------------------------------------------- 101 # ----------------------------------------------------------------------------
95 -# Tornado web server  
96 -# ----------------------------------------------------------------------------  
97 def main(): 102 def main():
  103 + '''
  104 + Tornado web server
  105 + '''
98 args = parse_cmdline_arguments() 106 args = parse_cmdline_arguments()
99 107
100 if args.version: 108 if args.version:
@@ -127,16 +135,17 @@ def main(): @@ -127,16 +135,17 @@ def main():
127 else: 135 else:
128 certs_dir = path.expanduser('~/.local/share/certs') 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 try: 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 path.join(certs_dir, 'privkey.pem')) 141 path.join(certs_dir, 'privkey.pem'))
134 except FileNotFoundError: 142 except FileNotFoundError:
135 - logging.critical(f'SSL certificates missing in {certs_dir}') 143 + logging.critical('SSL certificates missing in %s', certs_dir)
136 sys.exit(-1) 144 sys.exit(-1)
137 145
138 # --- run webserver ---------------------------------------------------- 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 #!/usr/bin/env python3 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 # python standard library 9 # python standard library
4 from os import path 10 from os import path
5 import sys 11 import sys
6 import base64 12 import base64
7 import uuid 13 import uuid
8 import logging.config 14 import logging.config
9 -import argparse 15 +# import argparse
10 import mimetypes 16 import mimetypes
11 import signal 17 import signal
12 import functools 18 import functools
13 import json 19 import json
14 -import ssl 20 +# import ssl
15 21
16 # user installed libraries 22 # user installed libraries
17 import tornado.ioloop 23 import tornado.ioloop
@@ -29,15 +35,15 @@ from perguntations.parser_markdown import md_to_html @@ -29,15 +35,15 @@ from perguntations.parser_markdown import md_to_html
29 class WebApplication(tornado.web.Application): 35 class WebApplication(tornado.web.Application):
30 def __init__(self, testapp, debug=False): 36 def __init__(self, testapp, debug=False):
31 handlers = [ 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 # (r'/root', MainHandler), # FIXME 44 # (r'/root', MainHandler), # FIXME
39 # (r'/ws', AdminSocketHandler), 45 # (r'/ws', AdminSocketHandler),
40 - (r'/', RootHandler), # TODO multiple tests 46 + (r'/', RootHandler),
41 ] 47 ]
42 48
43 settings = { 49 settings = {
@@ -54,15 +60,15 @@ class WebApplication(tornado.web.Application): @@ -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 def admin_only(func): 63 def admin_only(func):
  64 + '''
  65 + Decorator used to restrict access to the administrator
  66 + '''
60 @functools.wraps(func) 67 @functools.wraps(func)
61 async def wrapper(self, *args, **kwargs): 68 async def wrapper(self, *args, **kwargs):
62 if self.current_user != '0': 69 if self.current_user != '0':
63 raise tornado.web.HTTPError(403) # forbidden 70 raise tornado.web.HTTPError(403) # forbidden
64 - else:  
65 - await func(self, *args, **kwargs) 71 + await func(self, *args, **kwargs)
66 return wrapper 72 return wrapper
67 73
68 74
@@ -72,12 +78,20 @@ def admin_only(func): @@ -72,12 +78,20 @@ def admin_only(func):
72 class BaseHandler(tornado.web.RequestHandler): 78 class BaseHandler(tornado.web.RequestHandler):
73 @property 79 @property
74 def testapp(self): 80 def testapp(self):
  81 + '''
  82 + simplifies access to the application
  83 + '''
75 return self.application.testapp 84 return self.application.testapp
76 85
77 def get_current_user(self): 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 cookie = self.get_secure_cookie('user') 91 cookie = self.get_secure_cookie('user')
79 if cookie: 92 if cookie:
80 return cookie.decode('utf-8') 93 return cookie.decode('utf-8')
  94 + return None
81 95
82 96
83 # ---------------------------------------------------------------------------- 97 # ----------------------------------------------------------------------------
@@ -145,12 +159,15 @@ class AdminHandler(BaseHandler): @@ -145,12 +159,15 @@ class AdminHandler(BaseHandler):
145 @tornado.web.authenticated 159 @tornado.web.authenticated
146 @admin_only 160 @admin_only
147 async def get(self): 161 async def get(self):
  162 + '''
  163 + Admin page.
  164 + '''
148 cmd = self.get_query_argument('cmd', default=None) 165 cmd = self.get_query_argument('cmd', default=None)
149 166
150 if cmd == 'students_table': 167 if cmd == 'students_table':
151 data = {'data': self.testapp.get_students_state()} 168 data = {'data': self.testapp.get_students_state()}
152 self.write(json.dumps(data, default=str)) 169 self.write(json.dumps(data, default=str))
153 - elif cmd == 'test': # FIXME which test? 170 + elif cmd == 'test':
154 data = { 171 data = {
155 'data': { 172 'data': {
156 'title': self.testapp.testfactory['title'], 173 'title': self.testapp.testfactory['title'],
@@ -167,6 +184,9 @@ class AdminHandler(BaseHandler): @@ -167,6 +184,9 @@ class AdminHandler(BaseHandler):
167 @tornado.web.authenticated 184 @tornado.web.authenticated
168 @admin_only 185 @admin_only
169 async def post(self): 186 async def post(self):
  187 + '''
  188 + Executes commands from the admin page.
  189 + '''
170 cmd = self.get_body_argument('cmd', None) 190 cmd = self.get_body_argument('cmd', None)
171 value = self.get_body_argument('value', None) 191 value = self.get_body_argument('value', None)
172 192
@@ -180,8 +200,9 @@ class AdminHandler(BaseHandler): @@ -180,8 +200,9 @@ class AdminHandler(BaseHandler):
180 await self.testapp.update_student_password(uid=value, pw='') 200 await self.testapp.update_student_password(uid=value, pw='')
181 201
182 elif cmd == 'insert_student': 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 else: 207 else:
187 logging.error(f'Unknown command: "{cmd}"') 208 logging.error(f'Unknown command: "{cmd}"')
@@ -192,12 +213,19 @@ class AdminHandler(BaseHandler): @@ -192,12 +213,19 @@ class AdminHandler(BaseHandler):
192 # ---------------------------------------------------------------------------- 213 # ----------------------------------------------------------------------------
193 class LoginHandler(BaseHandler): 214 class LoginHandler(BaseHandler):
194 def get(self): 215 def get(self):
  216 + '''
  217 + Render login page.
  218 + '''
195 self.render('login.html', error='') 219 self.render('login.html', error='')
196 220
197 async def post(self): 221 async def post(self):
  222 + '''
  223 + Authenticates student (prefix 'l' are removed) and login.
  224 + '''
  225 +
198 uid = self.get_body_argument('uid').lstrip('l') 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 if login_ok: 230 if login_ok:
203 self.set_secure_cookie("user", str(uid), expires_days=30) 231 self.set_secure_cookie("user", str(uid), expires_days=30)
@@ -212,6 +240,9 @@ class LoginHandler(BaseHandler): @@ -212,6 +240,9 @@ class LoginHandler(BaseHandler):
212 class LogoutHandler(BaseHandler): 240 class LogoutHandler(BaseHandler):
213 @tornado.web.authenticated 241 @tornado.web.authenticated
214 def get(self): 242 def get(self):
  243 + '''
  244 + Logs out a user.
  245 + '''
215 self.clear_cookie('user') 246 self.clear_cookie('user')
216 self.redirect('/') 247 self.redirect('/')
217 248
@@ -223,8 +254,12 @@ class LogoutHandler(BaseHandler): @@ -223,8 +254,12 @@ class LogoutHandler(BaseHandler):
223 # handles root / to redirect students to /test and admininistrator to /admin 254 # handles root / to redirect students to /test and admininistrator to /admin
224 # ---------------------------------------------------------------------------- 255 # ----------------------------------------------------------------------------
225 class RootHandler(BaseHandler): 256 class RootHandler(BaseHandler):
  257 +
226 @tornado.web.authenticated 258 @tornado.web.authenticated
227 def get(self): 259 def get(self):
  260 + '''
  261 + Redirects students to the /test and admin to the /admin page.
  262 + '''
228 if self.current_user == '0': 263 if self.current_user == '0':
229 self.redirect('/admin') 264 self.redirect('/admin')
230 else: 265 else:
@@ -235,28 +270,38 @@ class RootHandler(BaseHandler): @@ -235,28 +270,38 @@ class RootHandler(BaseHandler):
235 # Serves files from the /public subdir of the topics. 270 # Serves files from the /public subdir of the topics.
236 # ---------------------------------------------------------------------------- 271 # ----------------------------------------------------------------------------
237 class FileHandler(BaseHandler): 272 class FileHandler(BaseHandler):
  273 + '''
  274 + Handles static files from questions like images, etc.
  275 + '''
  276 +
  277 +
238 @tornado.web.authenticated 278 @tornado.web.authenticated
239 async def get(self): 279 async def get(self):
  280 + '''
  281 + Returns requested file. Files are obtained from the 'public' directory
  282 + of each question.
  283 + '''
  284 +
240 uid = self.current_user 285 uid = self.current_user
241 ref = self.get_query_argument('ref', None) 286 ref = self.get_query_argument('ref', None)
242 image = self.get_query_argument('image', None) 287 image = self.get_query_argument('image', None)
243 content_type = mimetypes.guess_type(image)[0] 288 content_type = mimetypes.guess_type(image)[0]
244 289
245 if uid != '0': 290 if uid != '0':
246 - t = self.testapp.get_student_test(uid) 291 + test = self.testapp.get_student_test(uid)
247 else: 292 else:
248 logging.error('FIXME Cannot serve images for review.') 293 logging.error('FIXME Cannot serve images for review.')
249 raise tornado.web.HTTPError(404) # FIXME admin 294 raise tornado.web.HTTPError(404) # FIXME admin
250 295
251 - if t is None: 296 + if test is None:
252 raise tornado.web.HTTPError(404) # Not Found 297 raise tornado.web.HTTPError(404) # Not Found
253 298
254 - for q in t['questions']: 299 + for question in test['questions']:
255 # search for the question that contains the image 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 try: 303 try:
259 - f = open(filepath, 'rb') 304 + file = open(filepath, 'rb')
260 except FileNotFoundError: 305 except FileNotFoundError:
261 logging.error(f'File not found: {filepath}') 306 logging.error(f'File not found: {filepath}')
262 except PermissionError: 307 except PermissionError:
@@ -264,18 +309,23 @@ class FileHandler(BaseHandler): @@ -264,18 +309,23 @@ class FileHandler(BaseHandler):
264 except OSError: 309 except OSError:
265 logging.error(f'Error opening file: {filepath}') 310 logging.error(f'Error opening file: {filepath}')
266 else: 311 else:
267 - data = f.read()  
268 - f.close() 312 + data = file.read()
  313 + file.close()
269 self.set_header("Content-Type", content_type) 314 self.set_header("Content-Type", content_type)
270 self.write(data) 315 self.write(data)
271 await self.flush() 316 await self.flush()
272 - break # for loop 317 + break
273 318
274 319
275 # ---------------------------------------------------------------------------- 320 # ----------------------------------------------------------------------------
276 # Test shown to students 321 # Test shown to students
277 # ---------------------------------------------------------------------------- 322 # ----------------------------------------------------------------------------
278 class TestHandler(BaseHandler): 323 class TestHandler(BaseHandler):
  324 + '''
  325 + Generates test to student.
  326 + Receives answers, corrects the test and sends back the grade.
  327 + '''
  328 +
279 _templates = { 329 _templates = {
280 'radio': 'question-radio.html', 330 'radio': 'question-radio.html',
281 'checkbox': 'question-checkbox.html', 331 'checkbox': 'question-checkbox.html',
@@ -293,35 +343,43 @@ class TestHandler(BaseHandler): @@ -293,35 +343,43 @@ class TestHandler(BaseHandler):
293 # --- GET 343 # --- GET
294 @tornado.web.authenticated 344 @tornado.web.authenticated
295 async def get(self): 345 async def get(self):
  346 + '''
  347 + Generates test and sends to student
  348 + '''
296 uid = self.current_user 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 # --- POST 355 # --- POST
303 @tornado.web.authenticated 356 @tornado.web.authenticated
304 async def post(self): 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 ans = {} 369 ans = {}
312 - for i, q in enumerate(t['questions']): 370 + for i, question in enumerate(test['questions']):
313 qid = str(i) 371 qid = str(i)
314 if 'answered-' + qid in self.request.arguments: 372 if 'answered-' + qid in self.request.arguments:
315 ans[i] = self.get_body_arguments(qid) 373 ans[i] = self.get_body_arguments(qid)
316 374
317 # remove enclosing list in some question types 375 # remove enclosing list in some question types
318 - if q['type'] == 'radio': 376 + if question['type'] == 'radio':
319 if not ans[i]: 377 if not ans[i]:
320 ans[i] = None 378 ans[i] = None
321 else: 379 else:
322 ans[i] = ans[i][0] 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 ans[i] = ans[i][0] 383 ans[i] = ans[i][0]
326 384
327 # correct answered questions and logout 385 # correct answered questions and logout
@@ -331,7 +389,7 @@ class TestHandler(BaseHandler): @@ -331,7 +389,7 @@ class TestHandler(BaseHandler):
331 389
332 # show final grade and grades of other tests in the database 390 # show final grade and grades of other tests in the database
333 allgrades = self.testapp.get_student_grades_from_all_tests(uid) 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,6 +426,9 @@ class ReviewHandler(BaseHandler):
368 @tornado.web.authenticated 426 @tornado.web.authenticated
369 @admin_only 427 @admin_only
370 async def get(self): 428 async def get(self):
  429 + '''
  430 + Opens JSON file with a given corrected test and renders it
  431 + '''
371 test_id = self.get_query_argument('test_id', None) 432 test_id = self.get_query_argument('test_id', None)
372 logging.info(f'Review test {test_id}.') 433 logging.info(f'Review test {test_id}.')
373 fname = self.testapp.get_json_filename_of_test(test_id) 434 fname = self.testapp.get_json_filename_of_test(test_id)
@@ -376,28 +437,35 @@ class ReviewHandler(BaseHandler): @@ -376,28 +437,35 @@ class ReviewHandler(BaseHandler):
376 raise tornado.web.HTTPError(404) # Not Found 437 raise tornado.web.HTTPError(404) # Not Found
377 438
378 try: 439 try:
379 - f = open(path.expanduser(fname)) 440 + jsonfile = open(path.expanduser(fname))
380 except OSError: 441 except OSError:
381 logging.error(f'Cannot open "{fname}" for review.') 442 logging.error(f'Cannot open "{fname}" for review.')
382 else: 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 templ=self._templates) 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 tornado.ioloop.IOLoop.current().stop() 458 tornado.ioloop.IOLoop.current().stop()
395 logging.critical('Webserver stopped.') 459 logging.critical('Webserver stopped.')
396 sys.exit(0) 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 logging.info('Starting WebApplication (tornado)') 469 logging.info('Starting WebApplication (tornado)')
402 try: 470 try:
403 webapp = WebApplication(app, debug=debug) 471 webapp = WebApplication(app, debug=debug)
@@ -406,7 +474,7 @@ def run_webserver(app, ssl, port, debug): @@ -406,7 +474,7 @@ def run_webserver(app, ssl, port, debug):
406 raise 474 raise
407 475
408 try: 476 try:
409 - httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl) 477 + httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt)
410 except ValueError: 478 except ValueError:
411 logging.critical('Certificates cert.pem, privkey.pem not found') 479 logging.critical('Certificates cert.pem, privkey.pem not found')
412 sys.exit(1) 480 sys.exit(1)
@@ -414,7 +482,7 @@ def run_webserver(app, ssl, port, debug): @@ -414,7 +482,7 @@ def run_webserver(app, ssl, port, debug):
414 try: 482 try:
415 httpserver.listen(port) 483 httpserver.listen(port)
416 except OSError: 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 sys.exit(1) 486 sys.exit(1)
419 487
420 logging.info(f'Webserver listening on {port}... (Ctrl-C to stop)') 488 logging.info(f'Webserver listening on {port}... (Ctrl-C to stop)')
perguntations/static/css/test.css
@@ -5,7 +5,7 @@ html { @@ -5,7 +5,7 @@ html {
5 5
6 body { 6 body {
7 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 */
8 - background: #aaa; 8 + background: #bbb;
9 } 9 }
10 10
11 /* Hack to avoid name clash between pygments and mathjax */ 11 /* Hack to avoid name clash between pygments and mathjax */
perguntations/templates/test.html
@@ -38,13 +38,13 @@ @@ -38,13 +38,13 @@
38 <!-- My scripts --> 38 <!-- My scripts -->
39 <script defer src="/static/js/question_disabler.js"></script> 39 <script defer src="/static/js/question_disabler.js"></script>
40 <script defer src="/static/js/prevent_enter_submit.js"></script> 40 <script defer src="/static/js/prevent_enter_submit.js"></script>
41 - <script defer src="/static/js/clock.js"></script> 41 +
42 </head> 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 <a class="navbar-brand" href="#"> 48 <a class="navbar-brand" href="#">
49 <img src="/static/logo_horizontal.png" height="30" alt=""> 49 <img src="/static/logo_horizontal.png" height="30" alt="">
50 </a> 50 </a>
@@ -91,7 +91,11 @@ @@ -91,7 +91,11 @@
91 </div> 91 </div>
92 <div class="row"> 92 <div class="row">
93 <label for="duracao" class="col-sm-3">Duração:</label> 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 </div> 99 </div>
96 </h5> 100 </h5>
97 </div> 101 </div>
@@ -146,5 +150,44 @@ @@ -146,5 +150,44 @@
146 }); 150 });
147 </script> 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 </body> 192 </body>
150 </html> 193 </html>
perguntations/test.py
1 1
  2 +
2 # python standard library 3 # python standard library
3 from os import path 4 from os import path
4 import random 5 import random
@@ -19,17 +20,22 @@ class TestFactoryException(Exception): @@ -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 class TestFactory(dict): 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 def __init__(self, conf): 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 # --- set test defaults and then use given configuration 39 # --- set test defaults and then use given configuration
34 super().__init__({ # defaults 40 super().__init__({ # defaults
35 'title': '', 41 'title': '',
@@ -38,6 +44,7 @@ class TestFactory(dict): @@ -38,6 +44,7 @@ class TestFactory(dict):
38 'scale_max': 20.0, 44 'scale_max': 20.0,
39 'scale_min': 0.0, 45 'scale_min': 0.0,
40 'duration': 0, # 0=infinite 46 'duration': 0, # 0=infinite
  47 + 'autosubmit': False,
41 'debug': False, 48 'debug': False,
42 'show_ref': False 49 'show_ref': False
43 }) 50 })
@@ -49,8 +56,8 @@ class TestFactory(dict): @@ -49,8 +56,8 @@ class TestFactory(dict):
49 56
50 # --- find refs of all questions used in the test 57 # --- find refs of all questions used in the test
51 qrefs = {r for qq in self['questions'] for r in qq['ref']} 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 # --- for review, we are done. no factories needed 62 # --- for review, we are done. no factories needed
56 if self['review']: 63 if self['review']:
@@ -60,64 +67,66 @@ class TestFactory(dict): @@ -60,64 +67,66 @@ class TestFactory(dict):
60 # --- load and build question factories 67 # --- load and build question factories
61 self.question_factory = {} 68 self.question_factory = {}
62 69
63 - n = 1 70 + counter = 1
64 for file in self["files"]: 71 for file in self["files"]:
65 fullpath = path.normpath(path.join(self["questions_dir"], file)) 72 fullpath = path.normpath(path.join(self["questions_dir"], file))
66 (dirname, filename) = path.split(fullpath) 73 (dirname, filename) = path.split(fullpath)
67 74
68 - logger.info(f'Loading "{fullpath}"...') 75 + logger.info('Loading "%s"...', fullpath)
69 questions = load_yaml(fullpath) # , default=[]) 76 questions = load_yaml(fullpath) # , default=[])
70 77
71 - for i, q in enumerate(questions): 78 + for i, question in enumerate(questions):
72 # make sure every question in the file is a dictionary 79 # make sure every question in the file is a dictionary
73 - if not isinstance(q, dict): 80 + if not isinstance(question, dict):
74 msg = f'Question {i} in {file} is not a dictionary' 81 msg = f'Question {i} in {file} is not a dictionary'
75 raise TestFactoryException(msg) 82 raise TestFactoryException(msg)
76 83
77 # check if ref is missing, then set to '/path/file.yaml:3' 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 # check for duplicate refs 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 otherfile = path.join(other.question['path'], 92 otherfile = path.join(other.question['path'],
86 other.question['filename']) 93 other.question['filename'])
87 - msg = (f'Duplicate reference "{q["ref"]}" in files ' 94 + msg = (f'Duplicate reference "{question["ref"]}" in files '
88 f'"{otherfile}" and "{fullpath}".') 95 f'"{otherfile}" and "{fullpath}".')
89 raise TestFactoryException(msg) 96 raise TestFactoryException(msg)
90 97
91 # make factory only for the questions used in the test 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 'filename': filename, 102 'filename': filename,
96 'path': dirname, 103 'path': dirname,
97 'index': i # position in the file, 0 based 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 # check if all the questions can be correctly generated 109 # check if all the questions can be correctly generated
103 try: 110 try:
104 - self.question_factory[q['ref']].generate() 111 + self.question_factory[question['ref']].generate()
105 except Exception: 112 except Exception:
106 - msg = f'Failed to generate "{q["ref"]}"' 113 + msg = f'Failed to generate "{question["ref"]}"'
107 raise TestFactoryException(msg) 114 raise TestFactoryException(msg)
108 else: 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 qmissing = qrefs.difference(set(self.question_factory.keys())) 119 qmissing = qrefs.difference(set(self.question_factory.keys()))
113 if qmissing: 120 if qmissing:
114 raise TestFactoryException(f'Could not find questions {qmissing}.') 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 def sanity_checks(self): 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 # --- ref 130 # --- ref
122 if 'ref' not in self: 131 if 'ref' not in self:
123 raise TestFactoryException('Missing "ref" in configuration!') 132 raise TestFactoryException('Missing "ref" in configuration!')
@@ -125,7 +134,7 @@ class TestFactory(dict): @@ -125,7 +134,7 @@ class TestFactory(dict):
125 # --- check database 134 # --- check database
126 if 'database' not in self: 135 if 'database' not in self:
127 raise TestFactoryException('Missing "database" in configuration') 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 msg = f'Database "{self["database"]}" not found!' 138 msg = f'Database "{self["database"]}" not found!'
130 raise TestFactoryException(msg) 139 raise TestFactoryException(msg)
131 140
@@ -137,8 +146,8 @@ class TestFactory(dict): @@ -137,8 +146,8 @@ class TestFactory(dict):
137 # --- check if answers_dir is a writable directory 146 # --- check if answers_dir is a writable directory
138 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') 147 testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME')
139 try: 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 except OSError: 151 except OSError:
143 msg = f'Cannot write answers to directory "{self["answers_dir"]}"' 152 msg = f'Cannot write answers to directory "{self["answers_dir"]}"'
144 raise TestFactoryException(msg) 153 raise TestFactoryException(msg)
@@ -149,7 +158,7 @@ class TestFactory(dict): @@ -149,7 +158,7 @@ class TestFactory(dict):
149 158
150 if self['scale_points']: 159 if self['scale_points']:
151 smin, smax = self["scale_min"], self["scale_max"] 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 else: 162 else:
154 logger.info('Grades are just the sum of points defined for the ' 163 logger.info('Grades are just the sum of points defined for the '
155 'questions, not being scaled.') 164 'questions, not being scaled.')
@@ -240,6 +249,7 @@ class TestFactory(dict): @@ -240,6 +249,7 @@ class TestFactory(dict):
240 'questions': test, # list of Question instances 249 'questions': test, # list of Question instances
241 'answers_dir': self['answers_dir'], 250 'answers_dir': self['answers_dir'],
242 'duration': self['duration'], 251 'duration': self['duration'],
  252 + 'autosubmit': self['autosubmit'],
243 'scale_min': self['scale_min'], 253 'scale_min': self['scale_min'],
244 'scale_max': self['scale_max'], 254 'scale_max': self['scale_max'],
245 'show_points': self['show_points'], 255 'show_points': self['show_points'],