Commit 23b962453958e2926e5f27c253d5b95857774607
1 parent
70dd6593
Exists in
master
and in
1 other branch
- new 'autosubmit' option.
- show time remaining. - fix many pylint warnings - show type of submission
Showing
9 changed files
with
258 additions
and
174 deletions
Show diff stats
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
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
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'], | ... | ... |