serve.py 13.6 KB
#!/usr/bin/env python3
# -*- coding: utf-8 -*-


from os import path
import sys
import argparse
# import logging
import html
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)

# my code
from app import App

# ============================================================================
#   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

    def __init__(self, app):
        self.app = app

    @cherrypy.tools.accept(media='application/json') # FIXME
    @require(name_is('0'))
    def GET(self):
        data = {
            'online': self.app.get_online_students(),
            'offline': self.app.get_offline_students(),
            'allowed': list(self.app.get_allowed_students()),
            # 'finished': self.app.get_this_students_grades()
        }
        # print(dict(data['finished']))
        return json.dumps(data, default=str)

    @require(name_is('0'))
    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'),
        }

    @cherrypy.expose
    @require()
    def default(self, **args):
        uid = cherrypy.session.get(SESSION_KEY)
        if uid == '0':
            raise cherrypy.HTTPRedirect('/admin')
        else:
            raise cherrypy.HTTPRedirect('/test')
        # # FIXME
        # title = self.app.testfactory['title']
        # return '''Start test here: <a href="/test">{}</a>'''.format(title)
        # # raise cherrypy.HTTPRedirect('/test')


    @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
            raise cherrypy.HTTPRedirect('/admin') # FIXME
        else:                           # denied
            return self.template['login'].render()


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



    # --- STUDENTS -----------------------------------------------------------
    @cherrypy.expose
    @require(name_is('0'))
    def admin(self, **reset_pw):
        return self.template['admin'].render(
            online_students=self.app.get_online_students(),
            offline_students=self.app.get_offline_students(),
            allowed_students=self.app.get_allowed_students()
            )
        # t = TemplateLookup(directories=[TEMPLATES_DIR], input_encoding='utf-8').get_template('admin.html')
        # return t.render(online_students=online_students,
        #     offline_students=offline_students,
        #     allowed_students=allowed_students)





    # def students(self, **reset_pw):
        # if reset_pw:
        #     self.database.student_reset_pw(reset_pw)
        #     for num in reset_pw:
        #         cherrypy.log.error('Password updated for student %s.' % str(num), 'APPLICATION')

        # grades = self.database.test_grades2(self.testconf['ref'])
        # students = self.database.get_students()

        # template = self.templates.get_template('/students.html')
        # return template.render(students=students, tags=self.tags, grades=grades)

    # --- RESULTS ------------------------------------------------------------
    # @cherrypy.expose
    # @require()
    # def results(self):
    #     if self.testconf.get('practice', False):
    #         uid = cherrypy.session.get('userid')
    #         name = cherrypy.session.get('name')

    #         r = self.database.test_grades(self.testconf['ref'])
    #         template = self.templates.get_template('/results.html')
    #         return template.render(t=self.testconf, results=r, name=name, uid=uid)
    #     else:
    #         raise cherrypy.HTTPRedirect('/')



# ============================================================================
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('--server', 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('--show_ref', action='store_true',
    #     help='Show filename and ref field for each question')
    # argparser.add_argument('--show_points', action='store_true',
    #     help='Show normalized points for each question')
    # argparser.add_argument('--show_hints', action='store_true',
    #     help='Show hints in questions, if available')
    # argparser.add_argument('--save_answers', action='store_true',
    #     help='Saves answers in JSON format')
    # argparser.add_argument('--practice', action='store_true',
    #     help='Show correction results and allow repetitive resubmission of the test')
    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__':


    # --- path where this file is located
    SERVER_PATH = path.dirname(path.realpath(__file__))
    TEMPLATES_DIR = path.join(SERVER_PATH, 'templates')
    SESSION_KEY = 'userid'

    # --- parse command line arguments and build base test
    arg = parse_arguments()

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


    # testconf = test.TestFactory(filename, conf=vars(arg))


    # --- site wide configuration (valid for all apps)
    cherrypy.tools.secureheaders = cherrypy.Tool('before_finalize', secureheaders, priority=60)
    cherrypy.config.update({'tools.staticdir.root': SERVER_PATH})
    cherrypy.config.update(arg.server)
    conf = {
        '/': {
            # DO NOT DISABLE SESSIONS!
            'tools.sessions.on': True,
            'tools.sessions.timeout': 240,
            'tools.sessions.storage_type': 'ram',
            'tools.sessions.storage_path': 'sessions',
            # tools.sessions.secure = True
            # tools.sessions.httponly = True

            # Authentication
            'tools.auth.on': True,

            'tools.secureheaders.on': True,

            '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.staticdir.dir': './static',  # where to get js,css,jpg,...
            'tools.staticdir.on': True,
        }
    }

    # --- app specific configuration
    webapp = Root(app)
    webapp.adminwebservice = AdminWebService(app)

    cherrypy.tree.mount(webapp, '/', conf)

    # logger.info('Webserver listening at {}:{}'.format(
    #     cherrypy.config['server.socket_host'],
    #     cherrypy.config['server.socket_port']))

    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()
    cherrypy.log('Terminated OK ------------------------', 'APPLICATION')