serve.py 11.4 KB
#!/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: # 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
    with open(LOGGER_CONF,'r') as f:
        logging.config.dictConfig(yaml.load(f))

    # --- start application
    from app import App

    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,
        },
        '/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, ...
        },
    }

    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()