diff --git a/demo/demo.yaml b/demo/demo.yaml index 28c9873..f9587c4 100644 --- a/demo/demo.yaml +++ b/demo/demo.yaml @@ -21,7 +21,8 @@ title: Teste de demonstração (tutorial) # Duration in minutes. # (0 or undefined means infinite time) -duration: 60 +duration: 10 +autosubmit: true # Show points for each question, scale 0-20. # (default: false) @@ -29,9 +30,9 @@ show_points: true # scale final grade to the interval [scale_min, scale_max] # (default: scale to [0,20]) -scale_points: true scale_max: 20 scale_min: 0 +scale_points: true # ---------------------------------------------------------------------------- # Base path applied to the questions files and all the scripts diff --git a/package-lock.json b/package-lock.json index 14bf0d4..539b10c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3,89 +3,42 @@ "lockfileVersion": 1, "dependencies": { "@fortawesome/fontawesome-free": { - "version": "5.11.2", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz", - "integrity": "sha512-XiUPoS79r1G7PcpnNtq85TJ7inJWe0v+b5oZJZKb0pGHNIV6+UiNeQWiFGmuQ0aj7GEhnD/v9iqxIsjuRKtEnQ==" + "version": "5.13.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.13.0.tgz", + "integrity": "sha512-xKOeQEl5O47GPZYIMToj6uuA2syyFlq9EMSl2ui0uytjY9xbe8XS0pexNWmxrdcCyNGyDmLyYw5FtKsalBUeOg==" }, "bootstrap": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.3.1.tgz", - "integrity": "sha512-rXqOmH1VilAt2DyPzluTi2blhk17bO7ef+zLLPlWvG494pDxcM234pJ8wTc/6R40UWizAIIMgxjvxZg5kmsbag==" + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.4.1.tgz", + "integrity": "sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA==" }, "codemirror": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.2.tgz", - "integrity": "sha512-dwJ2HRPHm8w51WB5YTF9J7m6Z5dtkqbU9ntMZ1dqXyFB9IpjoUFDj80ahRVEoVanfIp6pfASJbOlbWdEf8FOzQ==" - }, - "commander": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.1.tgz", - "integrity": "sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ==" + "version": "5.52.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.52.2.tgz", + "integrity": "sha512-WCGCixNUck2HGvY8/ZNI1jYfxPG5cRHv0VjmWuNzbtCLz8qYA5d+je4QhSSCtCaagyeOwMi/HmmPTjBgiTm2lQ==" }, "datatables": { "version": "1.10.18", "resolved": "https://registry.npmjs.org/datatables/-/datatables-1.10.18.tgz", "integrity": "sha512-ntatMgS9NN6UMpwbmO+QkYJuKlVeMA2Mi0Gu/QxyIh+dW7ZjLSDhPT2tWlzjpIWEkDYgieDzS9Nu7bdQCW0sbQ==", "requires": { - "jquery": ">=1.7" + "jquery": "3.5.0" } }, - "esm": { - "version": "3.2.25", - "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", - "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==" - }, "jquery": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", - "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==" + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.0.tgz", + "integrity": "sha512-Xb7SVYMvygPxbFMpTFQiHh1J7HClEaThguL15N/Gg37Lri/qKyhRGZYzHRyLH8Stq3Aow0LsHO2O2ci86fCrNQ==" }, "mathjax": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.0.0.tgz", - "integrity": "sha512-z4uLbDHNbs/aRuR6zCcnzwFQuMixkHCcWqgVaommfK/3cA1Ahq7OXemn+m8JwTYcBApSHgcrSbPr9sm3sZFL+A==", - "requires": { - "mathjax-full": "git://github.com/mathjax/MathJax-src.git" - } - }, - "mathjax-full": { - "version": "git://github.com/mathjax/MathJax-src.git#0d74266e1820220d33cb6b29d4ca3575b352ac0d", - "from": "git://github.com/mathjax/MathJax-src.git", - "requires": { - "esm": "^3.2.25", - "mj-context-menu": "^0.2.0", - "speech-rule-engine": "^3.0.0-beta.6" - } - }, - "mj-context-menu": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.2.0.tgz", - "integrity": "sha512-yJxrWBHCjFZEHsZgfs7m5g9OSCNzsVYadW6f6lX3pgZL67vmodtSW/4zhsYmuDKweXfHs0M1kJge1uQIasWA+g==" + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.0.5.tgz", + "integrity": "sha512-9M7VulhltkD8sIebWutK/VfAD+m+6BIFqfpjDh9Pz/etoKUtjO6UMnOhUcDmNl6iApE8C9xrUmaMyNZkZAlrMw==" }, "popper.js": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.0.tgz", - "integrity": "sha512-+G+EkOPoE5S/zChTpmBSSDYmhXJ5PsW8eMhH8cP/CQHMFPBG/kC9Y5IIw6qNYgdJ+/COf0ddY2li28iHaZRSjw==" - }, - "speech-rule-engine": { - "version": "3.0.0-beta.6", - "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-3.0.0-beta.6.tgz", - "integrity": "sha512-B7gcT53jAsKpx7WvFYQcyUlFmgS3Wa9KlDy0FY8SOTa+Wz5EqmI0MpCD5/fYm8/2qiCPp8HwZg+H3cBgM+sNVw==", - "requires": { - "commander": "*", - "wicked-good-xpath": "*", - "xmldom-sre": "^0.1.31" - } - }, - "wicked-good-xpath": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", - "integrity": "sha1-gbDpXoZQ5JyUsiKY//hoa1VTz2w=" - }, - "xmldom-sre": { - "version": "0.1.31", - "resolved": "https://registry.npmjs.org/xmldom-sre/-/xmldom-sre-0.1.31.tgz", - "integrity": "sha512-f9s+fUkX04BxQf+7mMWAp5zk61pciie+fFLC9hX9UVvCeJQfNHRHXpeo5MPcR0EUf57PYLdt+ZO4f3Ipk2oZUw==" + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==" } } } diff --git a/package.json b/package.json index 4b77f7c..230fd55 100644 --- a/package.json +++ b/package.json @@ -2,12 +2,12 @@ "description": "Javascript libraries required to run the server", "email": "mjsb@uevora.pt", "dependencies": { - "@fortawesome/fontawesome-free": "^5.11.2", - "bootstrap": "^4.3", - "codemirror": "^5.49.2", + "@fortawesome/fontawesome-free": "^5.13.0", + "bootstrap": "^4.4.1", + "codemirror": "^5.52.2", "datatables": "^1.10", - "jquery": "^3.4.1", - "mathjax": "^3", - "popper.js": "^1.16.0" + "jquery": "^3.5.0", + "mathjax": "^3.0.5", + "popper.js": "^1.16.1" } } diff --git a/perguntations/__init__.py b/perguntations/__init__.py index d749702..b3d98ee 100644 --- a/perguntations/__init__.py +++ b/perguntations/__init__.py @@ -32,7 +32,7 @@ proof of submission and for review. ''' APP_NAME = 'perguntations' -APP_VERSION = '2020.03.dev1' +APP_VERSION = '2020.04.dev1' APP_DESCRIPTION = __doc__ __author__ = 'Miguel Barão' diff --git a/perguntations/main.py b/perguntations/main.py index 117b18e..35dfc80 100644 --- a/perguntations/main.py +++ b/perguntations/main.py @@ -18,12 +18,15 @@ from . import APP_NAME, APP_VERSION # ---------------------------------------------------------------------------- def parse_cmdline_arguments(): + ''' + Get command line arguments + ''' parser = argparse.ArgumentParser( description='Server for online tests. Enrolled students and tests ' 'have to be previously configured. Please read the documentation ' 'included with this software before running the server.') parser.add_argument('testfile', - type=str, nargs='?', # FIXME only one test supported + type=str, nargs='?', help='tests in YAML format') parser.add_argument('--allow-all', action='store_true', @@ -47,6 +50,10 @@ def parse_cmdline_arguments(): # ---------------------------------------------------------------------------- def get_logger_config(debug=False): + ''' + Load logger configuration from ~/.config directory if exists, + otherwise set default paramenters. + ''' if debug: filename = 'logger-debug.yaml' level = 'DEBUG' @@ -92,9 +99,10 @@ def get_logger_config(debug=False): # ---------------------------------------------------------------------------- -# Tornado web server -# ---------------------------------------------------------------------------- def main(): + ''' + Tornado web server + ''' args = parse_cmdline_arguments() if args.version: @@ -127,16 +135,17 @@ def main(): else: certs_dir = path.expanduser('~/.local/share/certs') - ssl_ctx = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) try: - ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'), + ssl_opt.load_cert_chain(path.join(certs_dir, 'cert.pem'), path.join(certs_dir, 'privkey.pem')) except FileNotFoundError: - logging.critical(f'SSL certificates missing in {certs_dir}') + logging.critical('SSL certificates missing in %s', certs_dir) sys.exit(-1) # --- run webserver ---------------------------------------------------- - run_webserver(app=testapp, ssl=ssl_ctx, port=args.port, debug=args.debug) + run_webserver(app=testapp, ssl_opt=ssl_opt, port=args.port, + debug=args.debug) # ---------------------------------------------------------------------------- diff --git a/perguntations/serve.py b/perguntations/serve.py index 68f3289..ce692d2 100644 --- a/perguntations/serve.py +++ b/perguntations/serve.py @@ -1,17 +1,23 @@ #!/usr/bin/env python3 +''' +Handles the web and html part of the application interface. +The tornadoweb framework is used. +''' + + # python standard library from os import path import sys import base64 import uuid import logging.config -import argparse +# import argparse import mimetypes import signal import functools import json -import ssl +# import ssl # user installed libraries import tornado.ioloop @@ -29,15 +35,15 @@ from perguntations.parser_markdown import md_to_html class WebApplication(tornado.web.Application): def __init__(self, testapp, debug=False): handlers = [ - (r'/login', LoginHandler), - (r'/logout', LogoutHandler), - (r'/test', TestHandler), - (r'/review', ReviewHandler), - (r'/admin', AdminHandler), - (r'/file', FileHandler), + (r'/login', LoginHandler), + (r'/logout', LogoutHandler), + (r'/test', TestHandler), + (r'/review', ReviewHandler), + (r'/admin', AdminHandler), + (r'/file', FileHandler), # (r'/root', MainHandler), # FIXME # (r'/ws', AdminSocketHandler), - (r'/', RootHandler), # TODO multiple tests + (r'/', RootHandler), ] settings = { @@ -54,15 +60,15 @@ class WebApplication(tornado.web.Application): # ---------------------------------------------------------------------------- -# Decorator used to restrict access to the administrator -# ---------------------------------------------------------------------------- def admin_only(func): + ''' + Decorator used to restrict access to the administrator + ''' @functools.wraps(func) async def wrapper(self, *args, **kwargs): if self.current_user != '0': raise tornado.web.HTTPError(403) # forbidden - else: - await func(self, *args, **kwargs) + await func(self, *args, **kwargs) return wrapper @@ -72,12 +78,20 @@ def admin_only(func): class BaseHandler(tornado.web.RequestHandler): @property def testapp(self): + ''' + simplifies access to the application + ''' return self.application.testapp def get_current_user(self): + ''' + HTML is stateless, so a cookie is used to identify the user. + This function returns the cookie for the current user. + ''' cookie = self.get_secure_cookie('user') if cookie: return cookie.decode('utf-8') + return None # ---------------------------------------------------------------------------- @@ -145,12 +159,15 @@ class AdminHandler(BaseHandler): @tornado.web.authenticated @admin_only async def get(self): + ''' + Admin page. + ''' cmd = self.get_query_argument('cmd', default=None) if cmd == 'students_table': data = {'data': self.testapp.get_students_state()} self.write(json.dumps(data, default=str)) - elif cmd == 'test': # FIXME which test? + elif cmd == 'test': data = { 'data': { 'title': self.testapp.testfactory['title'], @@ -167,6 +184,9 @@ class AdminHandler(BaseHandler): @tornado.web.authenticated @admin_only async def post(self): + ''' + Executes commands from the admin page. + ''' cmd = self.get_body_argument('cmd', None) value = self.get_body_argument('value', None) @@ -180,8 +200,9 @@ class AdminHandler(BaseHandler): await self.testapp.update_student_password(uid=value, pw='') elif cmd == 'insert_student': - s = json.loads(value) - self.testapp.insert_new_student(uid=s['number'], name=s['name']) + student = json.loads(value) + self.testapp.insert_new_student(uid=student['number'], + name=student['name']) else: logging.error(f'Unknown command: "{cmd}"') @@ -192,12 +213,19 @@ class AdminHandler(BaseHandler): # ---------------------------------------------------------------------------- class LoginHandler(BaseHandler): def get(self): + ''' + Render login page. + ''' self.render('login.html', error='') async def post(self): + ''' + Authenticates student (prefix 'l' are removed) and login. + ''' + uid = self.get_body_argument('uid').lstrip('l') - pw = self.get_body_argument('pw') - login_ok = await self.testapp.login(uid, pw) + password = self.get_body_argument('pw') + login_ok = await self.testapp.login(uid, password) if login_ok: self.set_secure_cookie("user", str(uid), expires_days=30) @@ -212,6 +240,9 @@ class LoginHandler(BaseHandler): class LogoutHandler(BaseHandler): @tornado.web.authenticated def get(self): + ''' + Logs out a user. + ''' self.clear_cookie('user') self.redirect('/') @@ -223,8 +254,12 @@ class LogoutHandler(BaseHandler): # handles root / to redirect students to /test and admininistrator to /admin # ---------------------------------------------------------------------------- class RootHandler(BaseHandler): + @tornado.web.authenticated def get(self): + ''' + Redirects students to the /test and admin to the /admin page. + ''' if self.current_user == '0': self.redirect('/admin') else: @@ -235,28 +270,38 @@ class RootHandler(BaseHandler): # Serves files from the /public subdir of the topics. # ---------------------------------------------------------------------------- class FileHandler(BaseHandler): + ''' + Handles static files from questions like images, etc. + ''' + + @tornado.web.authenticated async def get(self): + ''' + Returns requested file. Files are obtained from the 'public' directory + of each question. + ''' + uid = self.current_user ref = self.get_query_argument('ref', None) image = self.get_query_argument('image', None) content_type = mimetypes.guess_type(image)[0] if uid != '0': - t = self.testapp.get_student_test(uid) + test = self.testapp.get_student_test(uid) else: logging.error('FIXME Cannot serve images for review.') raise tornado.web.HTTPError(404) # FIXME admin - if t is None: + if test is None: raise tornado.web.HTTPError(404) # Not Found - for q in t['questions']: + for question in test['questions']: # search for the question that contains the image - if q['ref'] == ref: - filepath = path.join(q['path'], 'public', image) + if question['ref'] == ref: + filepath = path.join(question['path'], 'public', image) try: - f = open(filepath, 'rb') + file = open(filepath, 'rb') except FileNotFoundError: logging.error(f'File not found: {filepath}') except PermissionError: @@ -264,18 +309,23 @@ class FileHandler(BaseHandler): except OSError: logging.error(f'Error opening file: {filepath}') else: - data = f.read() - f.close() + data = file.read() + file.close() self.set_header("Content-Type", content_type) self.write(data) await self.flush() - break # for loop + break # ---------------------------------------------------------------------------- # Test shown to students # ---------------------------------------------------------------------------- class TestHandler(BaseHandler): + ''' + Generates test to student. + Receives answers, corrects the test and sends back the grade. + ''' + _templates = { 'radio': 'question-radio.html', 'checkbox': 'question-checkbox.html', @@ -293,35 +343,43 @@ class TestHandler(BaseHandler): # --- GET @tornado.web.authenticated async def get(self): + ''' + Generates test and sends to student + ''' uid = self.current_user - t = self.testapp.get_student_test(uid) # reloading returns same test - if t is None: - t = await self.testapp.generate_test(uid) - self.render('test.html', t=t, md=md_to_html, templ=self._templates) + test = self.testapp.get_student_test(uid) # reloading returns same test + if test is None: + test = await self.testapp.generate_test(uid) + self.render('test.html', t=test, md=md_to_html, templ=self._templates) # --- POST @tornado.web.authenticated async def post(self): - uid = self.current_user + ''' + Receives answers, fixes some html weirdness, corrects test and + sends back the grade. - # self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} - # build dictionary ans={0: 'answer0', 1:, 'answer1', ...} - # unanswered questions not included. - t = self.testapp.get_student_test(uid) + self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} + builds dictionary ans={0: 'answer0', 1:, 'answer1', ...} + unanswered questions not included. + ''' + + uid = self.current_user + test = self.testapp.get_student_test(uid) ans = {} - for i, q in enumerate(t['questions']): + for i, question in enumerate(test['questions']): qid = str(i) if 'answered-' + qid in self.request.arguments: ans[i] = self.get_body_arguments(qid) # remove enclosing list in some question types - if q['type'] == 'radio': + if question['type'] == 'radio': if not ans[i]: ans[i] = None else: ans[i] = ans[i][0] - elif q['type'] in ('text', 'text-regex', 'textarea', - 'numeric-interval'): + elif question['type'] in ('text', 'text-regex', 'textarea', + 'numeric-interval'): ans[i] = ans[i][0] # correct answered questions and logout @@ -331,7 +389,7 @@ class TestHandler(BaseHandler): # show final grade and grades of other tests in the database allgrades = self.testapp.get_student_grades_from_all_tests(uid) - self.render('grade.html', t=t, allgrades=allgrades) + self.render('grade.html', t=test, allgrades=allgrades) # ---------------------------------------------------------------------------- @@ -368,6 +426,9 @@ class ReviewHandler(BaseHandler): @tornado.web.authenticated @admin_only async def get(self): + ''' + Opens JSON file with a given corrected test and renders it + ''' test_id = self.get_query_argument('test_id', None) logging.info(f'Review test {test_id}.') fname = self.testapp.get_json_filename_of_test(test_id) @@ -376,28 +437,35 @@ class ReviewHandler(BaseHandler): raise tornado.web.HTTPError(404) # Not Found try: - f = open(path.expanduser(fname)) + jsonfile = open(path.expanduser(fname)) except OSError: logging.error(f'Cannot open "{fname}" for review.') else: - with f: - t = json.load(f) - self.render('review.html', t=t, md=md_to_html, + with jsonfile: + test = json.load(jsonfile) + self.render('review.html', t=test, md=md_to_html, templ=self._templates) # ---------------------------------------------------------------------------- -def signal_handler(signal, frame): - r = input(' --> Stop webserver? (yes/no) ') - if r.lower() == 'yes': +def signal_handler(sig, frame): + ''' + Catches Ctrl-C and stops webserver + ''' + reply = input(' --> Stop webserver? (yes/no) ') + if reply.lower() == 'yes': tornado.ioloop.IOLoop.current().stop() logging.critical('Webserver stopped.') sys.exit(0) # ---------------------------------------------------------------------------- -def run_webserver(app, ssl, port, debug): - # --- create web application --------------------------------------------- +def run_webserver(app, ssl_opt, port, debug): + ''' + Starts and runs webserver until a SIGINT signal (Ctrl-C) is received. + ''' + + # --- create web application logging.info('Starting WebApplication (tornado)') try: webapp = WebApplication(app, debug=debug) @@ -406,7 +474,7 @@ def run_webserver(app, ssl, port, debug): raise try: - httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl) + httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt) except ValueError: logging.critical('Certificates cert.pem, privkey.pem not found') sys.exit(1) @@ -414,7 +482,7 @@ def run_webserver(app, ssl, port, debug): try: httpserver.listen(port) except OSError: - logger.critical(f'Cannot bind port {port}. Already in use?') + logging.critical(f'Cannot bind port {port}. Already in use?') sys.exit(1) logging.info(f'Webserver listening on {port}... (Ctrl-C to stop)') diff --git a/perguntations/static/css/test.css b/perguntations/static/css/test.css index 25dbd39..2057143 100644 --- a/perguntations/static/css/test.css +++ b/perguntations/static/css/test.css @@ -5,7 +5,7 @@ html { body { padding-top: 100px; /* make room at top of page for the navbar */ - background: #aaa; + background: #bbb; } /* Hack to avoid name clash between pygments and mathjax */ diff --git a/perguntations/templates/test.html b/perguntations/templates/test.html index da24332..c29eabf 100644 --- a/perguntations/templates/test.html +++ b/perguntations/templates/test.html @@ -38,13 +38,13 @@ - + -
+ -