#!/usr/bin/env python3 # -*- coding: utf-8 -*- from os import path import sys import argparse import logging.config import json try: import cherrypy from cherrypy.lib import auth_digest from mako.lookup import TemplateLookup except ImportError: print('Some python packages are missing. See README.md for instructions.') sys.exit(1) from tools import load_yaml # ============================================================================ # Authentication # ============================================================================ def check_auth(*args, **kwargs): """A tool that looks in config for 'auth.require'. If found and it is not None, a login is required and the entry is evaluated as a list of conditions that the user must fulfill""" conditions = cherrypy.request.config.get('auth.require', None) if conditions is not None: username = cherrypy.session.get(SESSION_KEY) if username: # user logged in cherrypy.request.login = username for condition in conditions: # A condition is just a callable that returns true or false if not condition(): raise cherrypy.HTTPRedirect("/") else: # user not currently logged in raise cherrypy.HTTPRedirect("/login") cherrypy.tools.auth = cherrypy.Tool('before_handler', check_auth) # A decorator that appends conditions to the auth.require config variable. def require(*conditions): def decorate(f): if not hasattr(f, '_cp_config'): f._cp_config = dict() f._cp_config.setdefault('auth.require', []).extend(conditions) return f return decorate def name_is(reqd_username): return lambda: reqd_username == cherrypy.request.login # ============================================================================ # Improve cherrypy security # http://docs.cherrypy.org/en/latest/advanced.html#securing-your-server # ============================================================================ def secureheaders(): headers = cherrypy.response.headers headers['X-Frame-Options'] = 'DENY' headers['X-XSS-Protection'] = '1; mode=block' # FIXME disabled because MathJax requires unsafe javascript eval: # headers['Content-Security-Policy'] = "default-src 'self'" if (cherrypy.server.ssl_certificate != None and cherrypy.server.ssl_private_key != None): headers['Strict-Transport-Security'] = 'max-age=31536000' # one year # ============================================================================ # Admin webservice # ============================================================================ class AdminWebService(object): exposed = True _cp_config = { 'auth.require': [name_is('0')] } def __init__(self, app): self.app = app @cherrypy.tools.accept(media='application/json') # FIXME def GET(self): data = { 'students': self.app.get_students_state(), 'test': self.app.testfactory } return json.dumps(data, default=str) @cherrypy.tools.accept(media='application/json') # FIXME def POST(self, **args): # print('POST', args) # FIXME if args['cmd'] == 'allow': if args['value'] == 'true': return self.app.allow_student(args['name']) else: return self.app.deny_student(args['name']) elif args['cmd'] == 'reset': return self.app.reset_password(args['name']) elif args['cmd'] == 'insert': return self.app.insert_new_student(uid=args['number'], name=args['name']) else: print(args) # ============================================================================ # Student webservice # ============================================================================ class StudentWebService(object): exposed = True _cp_config = { 'auth.require': [] } def __init__(self, app): self.app = app @cherrypy.tools.accept(media='application/json') # FIXME def POST(self, **args): uid = cherrypy.session.get(SESSION_KEY) if args['cmd'] == 'focus': v = json.loads(args['value']) self.app.set_student_focus(uid=args['number'], value=v) # ============================================================================ # Webserver root # ============================================================================ class Root(object): def __init__(self, app): self.app = app t = TemplateLookup(directories=[TEMPLATES_DIR], input_encoding='utf-8') self.template = { 'login': t.get_template('/login.html'), 'test': t.get_template('/test.html'), 'grade': t.get_template('/grade.html'), 'admin': t.get_template('/admin.html'), 'review': t.get_template('/review.html'), } # --- DEFAULT ------------------------------------------------------------ @cherrypy.expose @require() def default(self, *args, **kwargs): uid = cherrypy.session.get(SESSION_KEY) if uid == '0': raise cherrypy.HTTPRedirect('/admin') else: raise cherrypy.HTTPRedirect('/test') # --- LOGIN -------------------------------------------------------------- @cherrypy.expose def login(self, uid=None, pw=None): if uid is None or pw is None: # first try return self.template['login'].render() if self.app.login(uid, pw): # ok cherrypy.session[SESSION_KEY] = cherrypy.request.login = uid self.app.set_user_agent(uid, cherrypy.request.headers.get('User-Agent', '')) self.app.set_user_ip(uid, cherrypy.request.remote.ip) raise cherrypy.HTTPRedirect('/') else: # denied return self.template['login'].render() # --- LOGOUT ------------------------------------------------------------- @cherrypy.expose @require() def logout(self): uid = cherrypy.session.get(SESSION_KEY) cherrypy.lib.sessions.expire() # session coockie expires client side cherrypy.session[SESSION_KEY] = cherrypy.request.login = None cherrypy.log.error('Student {0} logged out.'.format(uid), 'APPLICATION') self.app.logout(uid) raise cherrypy.HTTPRedirect('/') # --- TEST --------------------------------------------------------------- # Get student number and assigned questions from current session. # If it's the first time, create instance of the test and register the # time. @cherrypy.expose @require() def test(self): uid = cherrypy.session.get(SESSION_KEY) test = self.app.get_test(uid) or self.app.generate_test(uid) return self.template['test'].render(t=test) # --- CORRECT ------------------------------------------------------------ @cherrypy.expose @require() def correct(self, **kwargs): # receives dictionary with answers # kwargs = {'answered-xpto': 'on', 'xpto': '13.45', ...} # Format: # checkbox - all off -> no key, 1 on -> string '0', >1 on -> ['0', '1'] # radio - all off -> no key, 1 on -> string '0' # text - always returns string. no answer '', otherwise 'dskdjs' uid = cherrypy.session.get(SESSION_KEY) name = self.app.get_student_name(uid) qq = self.app.get_test_qtypes(uid) # {'q1_ref': 'checkbox', ...} # each question that is marked to be classified must have an answer. # `ans` contains the answers to be corrected. The missing ones were # disabled by the student ans = {} for qref, qtype in qq.items(): if 'answered-' + qref in kwargs: # HTML HACK: checkboxes in html return None instead of an empty list if none is selected. Also, if only one is selected returns string instead of list of strings. default_ans = [] if qtype == 'checkbox' else None a = kwargs.get(qref, default_ans) if qtype == 'checkbox' and isinstance(a, str): a = [a] ans[qref] = a grade = self.app.correct_test(uid, ans) t = self.app.get_test(uid) self.app.logout(uid) # --- Expire session cherrypy.lib.sessions.expire() # session coockie expires client side cherrypy.session[SESSION_KEY] = cherrypy.request.login = None # --- Show result to student return self.template['grade'].render( t=t, allgrades=self.app.get_student_grades_from_all_tests(uid) ) # --- GIVEUP ------------------------------------------------------------- @cherrypy.expose @require() def giveup(self): uid = cherrypy.session.get(SESSION_KEY) name = self.app.get_student_name(uid) title = self.app.get_test(uid)['title'] grade = self.app.giveup_test(uid) self.app.logout(uid) # --- Expire session cherrypy.lib.sessions.expire() # session coockie expires client side cherrypy.session[SESSION_KEY] = cherrypy.request.login = None # --- Show result to student return self.template['grade'].render( title=title, student_id=uid + ' - ' + name, grade=grade, allgrades=self.app.get_student_grades_from_all_tests(uid) ) # --- FILE --------------------------------------------------------------- @cherrypy.expose @require() def file(self, ref, name): # serve a static file: userid, question ref, file name # only works for users running a test uid = cherrypy.session.get(SESSION_KEY) filename = self.app.get_file(uid, ref, name) return cherrypy.lib.static.serve_file(filename) # --- ADMIN -------------------------------------------------------------- @cherrypy.expose @require(name_is('0')) def admin(self, **reset_pw): return self.template['admin'].render() # --- REVIEW ------------------------------------------------------------- @cherrypy.expose @require(name_is('0')) def review(self, test_id): fname = self.app.get_json_filename_of_test(test_id) with open(fname) as f: t = json.load(f) return self.template['review'].render(t=t) @cherrypy.expose @require(name_is('0')) def absfile(self, name): filename = path.abspath(path.join(self.app.get_questions_path(), name)) return cherrypy.lib.static.serve_file(filename) # ============================================================================ def parse_arguments(): argparser = 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.') serverconf_file = path.normpath(path.join(SERVER_PATH, 'config', 'server.conf')) argparser.add_argument('--conf', default=serverconf_file, type=str, help='server configuration file') argparser.add_argument('--debug', action='store_true', help='Show datastructures when rendering questions') argparser.add_argument('testfile', type=str, nargs='+', help='test/exam in YAML format.') # FIXME only one exam supported at the moment return argparser.parse_args() # ============================================================================ if __name__ == '__main__': SERVER_PATH = path.dirname(path.realpath(__file__)) TEMPLATES_DIR = path.join(SERVER_PATH, 'templates') LOGGER_CONF = path.join(SERVER_PATH, 'config/logger.yaml') SESSION_KEY = 'userid' # --- parse command line arguments and build base test arg = parse_arguments() if arg.debug: # FIXME log.level DEBUG not working LOGGER_CONF = path.join(SERVER_PATH, 'config/logger-debug.yaml') filename = path.abspath(path.expanduser(arg.testfile[0])) # --- Setup logging logging.config.dictConfig(load_yaml(LOGGER_CONF)) # --- start application from app import App try: app = App(filename, vars(arg)) except Exception as e: logging.critical('Can\'t start application.') raise e # FIXME just for testing sys.exit(1) # --- create webserver webapp = Root(app) webapp.adminwebservice = AdminWebService(app) webapp.studentwebservice = StudentWebService(app) # --- site wide configuration (valid for all apps) cherrypy.tools.secureheaders = cherrypy.Tool('before_finalize', secureheaders, priority=60) cherrypy.config.update(arg.conf) # configuration file in /config conf = { '/': { 'tools.sessions.on': True, 'tools.sessions.timeout': 240, # sessions last 4 hours 'tools.sessions.storage_type': 'ram', # or 'file' 'tools.sessions.storage_path': 'sessions', # if storage_type='file' # tools.sessions.secure = True # tools.sessions.httponly = True # Turn on authentication (required for check_auth to work) 'tools.auth.on': True, 'tools.secureheaders.on': True, 'tools.staticdir.root': SERVER_PATH, }, '/adminwebservice': { 'request.dispatch': cherrypy.dispatch.MethodDispatcher(), 'tools.response_headers.on': True, 'tools.response_headers.headers': [('Content-Type', 'text/plain')], }, '/studentwebservice': { 'request.dispatch': cherrypy.dispatch.MethodDispatcher(), 'tools.response_headers.on': True, 'tools.response_headers.headers': [('Content-Type', 'text/plain')], }, '/static': { 'tools.auth.on': False, # everything in /static is public 'tools.staticdir.on': True, 'tools.staticdir.dir': 'static',# where to get js, css, ... }, } cherrypy.engine.unsubscribe('graceful', cherrypy.log.reopen_files) # FIXME what's this? # --- Start server cherrypy.tree.mount(webapp, script_name='/', config=conf) if hasattr(cherrypy.engine, "signal_handler"): cherrypy.engine.signal_handler.subscribe() if hasattr(cherrypy.engine, "console_control_handler"): cherrypy.engine.console_control_handler.subscribe() cherrypy.engine.start() cherrypy.engine.block() # ...App running... app.exit()