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

# python standard library
import os
import sys
import json
import base64
import uuid
import concurrent.futures
import logging.config
import argparse

# user installed libraries
try:
    import markdown
    import tornado.ioloop
    import tornado.web
    import tornado.httpserver
    from tornado import template, gen
except ImportError:
    print('Some python packages are missing. See README.md for instructions.')
    sys.exit(1)

# this project
from app import LearnApp
from tools import load_yaml, md

# A thread pool to be used for password hashing with bcrypt. FIXME and other things?
# executor = concurrent.futures.ThreadPoolExecutor(2)


# ============================================================================
# WebApplication - Tornado Web Server
# ============================================================================
class WebApplication(tornado.web.Application):
    def __init__(self, learnapp):
        handlers = [
            (r'/login', LoginHandler),
            (r'/logout', LogoutHandler),
            (r'/change_password', ChangePasswordHandler),
            (r'/question', QuestionHandler),
            (r'/', LearnHandler),
            (r'/(.+)', FileHandler),
        ]
        settings = {
            'template_path': os.path.join(os.path.dirname(__file__), 'templates'),
            'static_path':   os.path.join(os.path.dirname(__file__), 'static'),
            'static_url_prefix': '/static/',  # this is the default
            'xsrf_cookies': False, # FIXME see how to do it...
            'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
            'login_url': '/login',
            'debug': True,
        }
        super().__init__(handlers, **settings)
        self.learn = learnapp

# ============================================================================
# Handlers
# ============================================================================

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

    def get_current_user(self):
        cookie = self.get_secure_cookie("user")
        if cookie:
            user = cookie.decode('utf-8')
            # FIXME if the cookie exists but user is not in learn.online, this will force new login and store new (duplicate?) cookie. is this correct??
            if user in self.learn.online:
                return user

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

    # @gen.coroutine
    def post(self):
        uid = self.get_body_argument('uid')
        pw = self.get_body_argument('pw')

        if self.learn.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úmero ou senha incorrectos')

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

# ----------------------------------------------------------------------------
class ChangePasswordHandler(BaseHandler):
    @tornado.web.authenticated
    def post(self):
        uid = self.current_user
        pw = self.get_body_arguments('new_password')[0];

        if self.learn.change_password(uid, pw):
            notification = tornado.escape.to_unicode(
                self.render_string(
                    'notification.html',
                    type='success',
                    msg='A password foi alterada!'
                    )
                )
        else:
            notification = tornado.escape.to_unicode(self.render_string('notification.html', type='danger', msg='A password não foi alterada!'))
        self.write({'msg': notification})

# ----------------------------------------------------------------------------
# /learn
# ----------------------------------------------------------------------------
class LearnHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        uid = self.current_user
        self.render('learn.html',
            uid=uid,
            name=self.learn.get_student_name(uid),
            title=self.learn.get_title(),
            )

# ----------------------------------------------------------------------------
class FileHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self, filename):
        uid = self.current_user
        public_dir = self.learn.get_current_public_dir(uid)
        try:
            with open(os.path.join(public_dir, filename), 'rb') as f:
                self.write(f.read())
        except FileNotFoundError:
            raise tornado.web.HTTPError(404)

# ----------------------------------------------------------------------------
# respond to AJAX to get a JSON question
class QuestionHandler(BaseHandler):
    templates = {
        'information':  'question-information.html',
        'checkbox':     'question-checkbox.html',
        'radio':        'question-radio.html',
        'text':         'question-text.html',
        'text_regex':   'question-text.html',
        'text_numeric': 'question-text.html',
        'textarea':     'question-textarea.html',
    }

    @tornado.web.authenticated
    def get(self):
        self.redirect('/')

    @tornado.web.authenticated
    def post(self):
        # ref = self.get_body_arguments('question_ref')
        user = self.current_user
        answer = self.get_body_arguments('answer')
        next_question = self.learn.check_answer(user, answer)
        state = self.learn.get_student_state(user)  # all topics
        progress = self.learn.get_student_progress(user) # in the current topic

        if next_question is not None:
            question_html = self.render_string(
                self.templates[next_question['type']],
                question=next_question, # dictionary with the question
                md=md,                  # function that renders markdown to html
                )
            topics_html = self.render_string(
                'topics.html',
                state=state,
                topicname=self.learn.get_topic_name,    # function that translates topic references to names
                )

            self.write({
                'method': 'new_question',
                'params': {
                    'question': tornado.escape.to_unicode(question_html),
                    'state': tornado.escape.to_unicode(topics_html),
                    'progress': progress,
                    },
                })
        else:
            self.write({
                'method': 'shake',
                'params': {
                    'progress': progress,
                    },
                })

# ----------------------------------------------------------------------------
def main():
    SERVER_PATH = os.path.dirname(os.path.realpath(__file__))
    LOGGER_CONF = os.path.join(SERVER_PATH, 'config/logger.yaml')

    # --- Commandline argument parsing
    argparser = argparse.ArgumentParser(description='Server for online learning. Enrolled students and topics have to be previously configured. Please read the documentation included with this software before running the server.')
    # FIXME:
        # 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='Enable debug logging.')
        # argparser.add_argument('--allow-all', action='store_true',
            # help='Students are initially allowed to login (can be denied later)')
    argparser.add_argument('conffile', type=str, nargs='+', help='Topics configuration file in YAML format.') # FIXME only one supported at the moment
    arg = argparser.parse_args()

    # --- Setup logging
    try:
        logging.config.dictConfig(load_yaml(LOGGER_CONF))
    except: # FIXME  should this be done in a different way?
        print('An error ocurred while setting up the logging system.')
        print('Common causes:\n - inexistent directory "logs"?\n - write permission to "logs" directory?')
        sys.exit(1)

    # --- start application
    learnapp = LearnApp(arg.conffile[0])
    try:
        webapp = WebApplication(learnapp)
    except Exception as e:
        logging.critical('Can\'t start application.')
        # sys.exit(1)
        raise e  # FIXME

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

    # --- start webserver
    try:
        logging.info('Webserver running...')
        tornado.ioloop.IOLoop.current().start()
        # running...
    except KeyboardInterrupt:
        tornado.ioloop.IOLoop.current().stop()
        logging.info('Webserver stopped.')

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