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,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 | } |
package.json
@@ -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'], |