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

# python standard library
from os import path
import sys
import base64
import uuid
import logging.config
import argparse

# user installed libraries
import tornado.ioloop
import tornado.web
import tornado.httpserver
from tornado import template    #, gen

# this project
from learnapp import LearnApp
from tools import load_yaml, md_to_html


# ============================================================================
# WebApplication - Tornado Web Server
# ============================================================================
class WebApplication(tornado.web.Application):
    def __init__(self, learnapp, debug=False):
        handlers = [
            (r'/login',         LoginHandler),
            (r'/logout',        LogoutHandler),
            (r'/change_password', ChangePasswordHandler),
            (r'/question',      QuestionHandler),       # each question
            (r'/topic/(.+)',    TopicHandler),          # page for doing a topic
            # (r'/file/(.+)',   FileHandler),   # FIXME
            (r'/.*',            RootHandler),           # show list of topics
        ]
        settings = {
            'template_path': path.join(path.dirname(__file__), 'templates'),
            'static_path':   path.join(path.dirname(__file__), 'static'),
            'static_url_prefix': '/static/',
            'xsrf_cookies': True,
            'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
            'login_url': '/login',
            'debug': debug,
        }
        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')
            return user
            # 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})

# ----------------------------------------------------------------------------
# Main page:  /
# Shows a list of topics and proficiency (stars, locked).
# ----------------------------------------------------------------------------
class RootHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self):
        uid = self.current_user
        self.render('maintopics.html',
            uid=uid,
            name=self.learn.get_student_name(uid),
            state=self.learn.get_student_state(uid),
            title=self.learn.get_title()
            )

# ----------------------------------------------------------------------------
# Start a given topic:  /topic/...
# ----------------------------------------------------------------------------
class TopicHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self, topic):
        uid = self.current_user

        try:
            ok = self.learn.start_topic(uid, topic)
        except KeyError:
            self.redirect('/')
        else:
            if ok:
                self.render('topic.html',
                    uid=uid,
                    name=self.learn.get_student_name(uid),
                    )
            else:
                self.redirect('/')

# ----------------------------------------------------------------------------
# FIXME
class FileHandler(BaseHandler):
    @tornado.web.authenticated
    def get(self, filename):
        uid = self.current_user
        public_dir = self.learn.get_current_public_dir(uid)
        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()

# ----------------------------------------------------------------------------
# respond to AJAX to get a JSON question
# ----------------------------------------------------------------------------
class QuestionHandler(BaseHandler):
    templates = {
        'checkbox':         'question-checkbox.html',
        'radio':            'question-radio.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',
        # 'success':          'question-success.html',
        # 'warning':    '', FIXME
        # 'warn':       '', FIXME
        # 'alert':      '', FIXME
    }

    @tornado.web.authenticated
    def get(self):
        logging.debug('QuestionHandler.get()')
        user = self.current_user

        question = self.learn.get_student_question(user)
        template = self.templates[question['type']]
        question_html = self.render_string(template, question=question, md=md_to_html)

        self.write({
            'method': 'new_question',
            'params': {
                'question': tornado.escape.to_unicode(question_html),
                'progress': self.learn.get_student_progress(user) ,
                }
            })

    # handles answer posted
    @tornado.web.authenticated
    def post(self):
        logging.debug('QuestionHandler.post()')
        user = self.current_user

        # check answer and get next question (same, new or None)
        answer = self.get_body_arguments('answer')  # list
        if not answer:
            answer = None
        else:
            # answers returned in a list. fix depending on question type
            qtype = self.learn.get_student_question_type(user)
            if qtype in ('success', 'information', 'info'):  # FIXME unused?
                answer = None
            elif qtype != 'checkbox':   # radio, text, textarea, ...
                answer = answer[0]

        grade = self.learn.check_answer(user, answer)
        question = self.learn.get_student_question(user)

        if grade <= 0.999:      # wrong answer
            comments_html = self.render_string('comments.html', comments=question['comments'], md=md_to_html)
            self.write({
                'method': 'shake',
                'params': {
                    'progress': self.learn.get_student_progress(user),
                    'comments': tornado.escape.to_unicode(comments_html), # FIXME
                    }
                })
        else:                   # answer is correct
            if question is None:        # finished topic
                finished_topic_html = self.render_string('finished_topic.html')
                self.write({
                    'method': 'finished_topic',
                    'params': {
                        'question': tornado.escape.to_unicode(finished_topic_html)
                        }
                    })

            else:                       # continue with a new question
                template = self.templates[question['type']]
                question_html = self.render_string(template, question=question, md=md_to_html)
                self.write({
                    'method': 'new_question',
                    'params': {
                        'question': tornado.escape.to_unicode(question_html),
                        'progress': self.learn.get_student_progress(user),
                        }
                    })

# -------------------------------------------------------------------------
# Tornado web server
# ----------------------------------------------------------------------------
def main():
    # --- 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.')
    argparser.add_argument('conffile', type=str, nargs='+',
        help='Topics configuration file in YAML format.') # FIXME only one
    argparser.add_argument('--debug', action='store_true',
        help='Enable debug messages.')
    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
    logging.info('Starting App.')
    learnapp = LearnApp(arg.conffile[0])

    # --- create web application
    logging.info('Starting Web App (tornado)')
    try:
        webapp = WebApplication(learnapp, debug=arg.debug)
    except Exception as e:
        logging.critical('Failed to start application.')
        raise e

    # --- create webserver
    http_server = tornado.httpserver.HTTPServer(webapp,
        ssl_options={
            "certfile": "certs/cert.pem",
            "keyfile": "certs/key.pem"
        })
    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()