#!/usr/bin/env python3.6 # python standard library from os import path import sys import base64 import uuid import logging.config import argparse from concurrent.futures import ThreadPoolExecutor # user installed libraries import tornado.ioloop import tornado.web import tornado.httpserver from tornado import template #, gen from tornado.concurrent import run_on_executor from tornado.platform.asyncio import to_tornado_future # 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(), # get_topic_type=self.learn.get_topic_type, # function ) # ---------------------------------------------------------------------------- # 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('/') # ---------------------------------------------------------------------------- # Serves files from the /public subdir of the topics. # Based on https://bhch.github.io/posts/2017/12/serving-large-files-with-tornado-safely-without-blocking/ # ---------------------------------------------------------------------------- class FileHandler(BaseHandler): @tornado.web.authenticated async 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)) chunk_size = 1024 * 1024 # serve up to 1MiB multiple times try: f = open(filepath, 'rb') except FileNotFoundError: logging.error(f'File not found: {filepath}') except PermissionError: logging.error(f'No permission: {filepath}') else: with f: chunk = f.read(chunk_size) while chunk: try: self.write(chunk) # write the cunk to response await self.flush() # flush the current chunk to socket except iostream.StreamClosedError: # client closed the connection break finally: del chunk await gen.sleep(0.000000001) # 1 nanosecond (hack) # in tornnado 5.0 use `await asyncio.sleep(0)` instead chunk = f.read(chunk_size) # ---------------------------------------------------------------------------- # respond to AJAX to get a JSON question # ---------------------------------------------------------------------------- class QuestionHandler(BaseHandler): executor = ThreadPoolExecutor(max_workers=2) 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 } # Blocking function to be run on the executor @run_on_executor() def check_answer(self, user, answer): return self.learn.check_answer(user, answer) # --- get question to render @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) , } }) # --- post answer, returns what to do next: shake, new_question, finished @tornado.web.authenticated async 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] # check answer in another thread (nonblocking) grade = await to_tornado_future(self.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') try: learnapp = LearnApp(arg.conffile[0]) except Exception as e: logging.critical('Failed to start backend application') raise e # --- 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 web 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()