#!/usr/bin/env python3 # -*- coding: utf-8 -*- from os import path import sys import argparse import logging.config import html import json import yaml 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) # ============================================================================ # 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' 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': list(self.app.get_students_state().items()), 'test': self.app.testfactory } return json.dumps(data, default=str) 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']) # ============================================================================ # 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'), } # --- 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) if test is None: test = self.app.generate_test(uid) # try to generate a new test 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) student_name = self.app.get_student_name(uid) title = self.app.get_test(uid)['title'] 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) 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 + ' - ' + student_name, grade=grade, allgrades=self.app.get_student_grades_from_all_tests(uid) ) # --- ADMIN -------------------------------------------------------------- @cherrypy.expose @require(name_is('0')) def admin(self, **reset_pw): return self.template['admin'].render() # ============================================================================ 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: LOGGER_CONF = path.join(SERVER_PATH, 'config/logger-debug.yaml') # --- Setup logging with open(LOGGER_CONF,'r') as f: logging.config.dictConfig(yaml.load(f)) # --- start application from app import App # FIXME do not send args that were not defined in the commandline # this means options should be like --show-ref=true|false # and have no default value filename = path.abspath(path.expanduser(arg.testfile[0])) try: app = App(filename, vars(arg)) except: sys.exit(1) # --- create webserver webapp = Root(app) webapp.adminwebservice = AdminWebService(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, 'tools.staticdir.dir': 'static', # where to get js,css,jpg,... 'tools.staticdir.on': True, }, '/adminwebservice': { '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,jpg,... }, } 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()