serve.py 13 KB
#!/usr/bin/env python3.6

# base
from os import path
import sys
import argparse
import logging.config
import json
import base64
import uuid
# from mimetypes import guess_type


# packages
import tornado.ioloop
import tornado.web
import tornado.httpserver
from tornado import template, gen

# project
from tools import load_yaml, md_to_html
from app import App, AppException


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), # FIXME
            (r'/',              RootHandler), # TODO multiple tests
        ]

        settings = {
            'template_path': path.join(path.dirname(__file__), 'templates'),
            'static_path':   path.join(path.dirname(__file__), 'static'),
            'static_url_prefix': '/static/',
            'xsrf_cookies': False, # FIXME not needed on private network
            'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
            'login_url': '/login',
            'debug': debug,
        }

        super().__init__(handlers, **settings)
        self.testapp = testapp


# -------------------------------------------------------------------------
# Base handler common to all handlers.
# -------------------------------------------------------------------------
class BaseHandler(tornado.web.RequestHandler):
    @property
    def testapp(self):
        return self.application.testapp

    def get_current_user(self):
        cookie = self.get_secure_cookie('user')
        if cookie:
            return cookie.decode('utf-8')


# -------------------------------------------------------------------------
# /login  and  /logout
# -------------------------------------------------------------------------
class LoginHandler(BaseHandler):
    def get(self):
        self.render('login.html', error='')

    # @gen.coroutine
    def post(self):
        uid = self.get_body_argument('uid')
        if uid.startswith('l'):  # remove prefix 'l'
            uid = uid[1:]
        pw = self.get_body_argument('pw')

        if self.testapp.login(uid, pw):
            self.set_secure_cookie("user", str(uid), expires_days=30)
            self.redirect(self.get_argument("next", "/"))
        else:
            self.render("login.html",
                        error='Não autorizado ou número/senha inválido')


# -------------------------------------------------------------------------
class LogoutHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        self.testapp.logout(self.current_user)
        self.clear_cookie('user')
        self.redirect('/')


# -------------------------------------------------------------------------
# FIXME checkit
class FileHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        uid = self.current_user
        qref = self.get_query_argument('ref')
        qfile = self.get_query_argument('file')
        print(f'FileHandler: ref={ref}, file={file}')

        self.write(self.testapp.get_file(ref, filename))


        # if not os.path.isfile(file_location):
        #     raise tornado.web.HTTPError(status_code=404)

        # content_type, _ = guess_type(file_location)
        # self.add_header('Content-Type', content_type)
        # with open(file_location) as source_file:
        #     self.write(source_file.read())




        # public_dir = self.learn.get_current_public_dir(uid)   # FIXME!!!
        # filepath = path.expanduser(path.join(public_dir, filename))
        # try:
        #     f = open(filepath, 'rb')
        # except FileNotFoundError:
        #     raise tornado.web.HTTPError(404)
        # else:
        #     self.write(f.read())
        #     f.close()


# -------------------------------------------------------------------------
# FIXME images missing, needs testing
# -------------------------------------------------------------------------
class TestHandler(BaseHandler):
    templates = {
        'radio':        'question-radio.html',
        'checkbox':     'question-checkbox.html',
        'text':         'question-text.html',
        'text-regex':   'question-text.html',
        'numeric-interval': 'question-text.html',
        'textarea':     'question-textarea.html',
        # -- information panels --
        'information':  'question-information.html',
        'info':         'question-information.html',
        'warning':      'question-warning.html',
        'warn':         'question-warning.html',
        'alert':        'question-alert.html',
        'success':      'question-success.html',
    }

    # GET
    @tornado.web.authenticated
    def get(self):
        uid = self.current_user
        t = self.testapp.get_test(uid) or self.testapp.generate_test(uid)
        self.render('test.html', t=t, md=md_to_html, templ=self.templates)

    # POST
    @tornado.web.authenticated
    def post(self):
        uid = self.current_user

        # 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_test(uid)
        ans = {}
        for i, q in enumerate(t['questions']):
            qid = str(i)  # question id
            if 'answered-' + qid in self.request.arguments:
                ans[i] = self.get_body_arguments(qid)

                # remove list when it does not make sense...
                if q['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'):
                    ans[i] = ans[i][0]

        self.testapp.correct_test(uid, ans)
        self.testapp.logout(uid)
        self.clear_cookie('user')

        self.render('grade.html', t=t, allgrades=self.testapp.get_student_grades_from_all_tests(uid))


# --- REVIEW -------------------------------------------------------------
class ReviewHandler(BaseHandler):
    _templates = {
        'radio':        'review-question-radio.html',
        'checkbox':     'review-question-checkbox.html',
        'text':         'review-question-text.html',
        'text-regex':   'review-question-text.html',
        'numeric-interval': 'review-question-text.html',
        'textarea':     'review-question-text.html',
        # -- information panels --
        'information':  'question-information.html',
        'info':         'question-information.html',
        'warning':      'question-warning.html',
        'warn':         'question-warning.html',
        'alert':        'question-alert.html',
        'success':      'question-success.html',
    }

    @tornado.web.authenticated
    def get(self):
        uid = self.current_user
        if uid != '0':
            raise tornado.web.HTTPError(404)

        test_id = self.get_query_argument('test_id', None)
        try:
            fname = self.testapp.get_json_filename_of_test(test_id)
        except:
            raise tornado.web.HTTPError(404, 'Test ID not found.')

        try:
            f = 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, templ=self._templates)


# --- FILE -------------------------------------------------------------
# class      FIXME
#     @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)


# -------------------------------------------------------------------------
# FIXME this should be a post in the test with command giveup instead of correct...
class GiveupHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        uid = self.current_user
        t = self.testapp.giveup_test(uid)
        self.testapp.logout(uid)

        # --- Show result to student
        self.render('grade.html', t=t, allgrades=self.testapp.get_student_grades_from_all_tests(uid))


# -------------------------------------------------------------------------
# FIXME list available tests
class RootHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        if self.current_user == '0':
            self.redirect('/admin')
        else:
            self.redirect('/test')


# -------------------------------------------------------------------------
class AdminHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        if self.current_user != '0':
            raise tornado.web.HTTPError(404) # FIXME denied or not found??

        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?
            data = {
                'data': {
                    'title': self.testapp.testfactory['title'],
                    'ref': self.testapp.testfactory['ref'],
                    'filename': self.testapp.testfactory['filename'],
                    'database': self.testapp.testfactory['database'],
                    'answers_dir': self.testapp.testfactory['answers_dir'],
                    }
                }
            self.write(json.dumps(data, default=str))

        else:
            self.render('admin.html')

    @tornado.web.authenticated
    def post(self):
        if self.current_user != '0':
            self.redirect('/')

        cmd = self.get_body_argument('cmd', None)
        value = self.get_body_argument('value', None)

        if cmd == 'allow':
            self.testapp.allow_student(value)

        elif cmd == 'deny':
            self.testapp.deny_student(value)

        elif cmd == 'reset_password':
            self.testapp.reset_password(value)

        elif cmd == 'insert_student':
            s = json.loads(value)
            self.testapp.insert_new_student(uid=s['number'], name=s['name'])

        else:
            logging.error(f'Unknown command in post: "{cmd}"')


# -------------------------------------------------------------------------
# Tornado web server
# -------------------------------------------------------------------------
def main():
    # --- Commandline argument parsing
    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.')
    argparser.add_argument('testfile', type=str, nargs='+', help='test/exam in YAML format.') # FIXME only one exam supported at the moment
    argparser.add_argument('--allow-all', action='store_true', help='Students are initially allowed to login (can be denied later)')
    argparser.add_argument('--debug', action='store_true', help='Enable debug messages')
    argparser.add_argument('--show-ref', action='store_true', help='Show question references')
    argparser.add_argument('--review', action='store_true', help='Review mode: don\'t generate test')

    arg = argparser.parse_args()

    # --- Setup logging
    logger_file = 'logger-debug.yaml' if arg.debug else 'logger.yaml'
    SERVER_PATH = path.dirname(path.realpath(__file__))
    LOGGER_CONF = path.join(SERVER_PATH, f'config/{logger_file}')

    try:
        logging.config.dictConfig(load_yaml(LOGGER_CONF))
    except:
        print('An error ocurred while setting up the logging system.')
        sys.exit(1)
    logging.info('===============================================')

    # --- start application
    config = {
        'filename': arg.testfile[0] or '',
        'debug': arg.debug,
        'allow_all': arg.allow_all,
        'show_ref': arg.show_ref,
        'review': arg.review,
    }

    try:
        testapp = App(config)
    except AppException:
        logging.critical('Failed to start application.')
        sys.exit(1)

    # --- create web application
    try:
        webapp = WebApplication(testapp, debug=arg.debug)
    except Exception as e:
        logging.critical('Can\'t start application.')
        raise e

    # --- create webserver
    http_server = tornado.httpserver.HTTPServer(webapp,
        ssl_options={
            "certfile": "certs/cert.crt",
            "keyfile": "certs/cert.key"
            })
    http_server.listen(8443)

    # --- run webserver
    logging.info('Webserver running...')

    try:
        tornado.ioloop.IOLoop.current().start()  # running...
    except KeyboardInterrupt:
        tornado.ioloop.IOLoop.current().stop()

    logging.critical('Webserver stopped.')

# -------------------------------------------------------------------------
if __name__ == "__main__":
    main()