From 4307381694f71f25ff80f9b14a046ddb6fbfbd30 Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Tue, 14 Feb 2023 18:43:40 +0000 Subject: [PATCH] Large refactoring --- aprendizations/__init__.py | 4 ++-- aprendizations/learnapp.py | 22 ++++++++-------------- aprendizations/questions.py | 6 +++--- aprendizations/serve.py | 275 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- aprendizations/templates/login.html | 2 +- aprendizations/templates/notification.html | 16 ++++++++++++---- aprendizations/tools.py | 50 ++++++++++++++++++++++++++++---------------------- mypy.ini | 6 +++--- setup.py | 2 +- 9 files changed, 161 insertions(+), 222 deletions(-) diff --git a/aprendizations/__init__.py b/aprendizations/__init__.py index 401ae60..fb9b868 100644 --- a/aprendizations/__init__.py +++ b/aprendizations/__init__.py @@ -30,10 +30,10 @@ are progressively uncovered as the students progress. ''' APP_NAME = 'aprendizations' -APP_VERSION = '2022.12.dev1' +APP_VERSION = '2023.2.dev1' APP_DESCRIPTION = __doc__ __author__ = 'Miguel Barão' -__copyright__ = 'Copyright © 2022, Miguel Barão' +__copyright__ = 'Copyright © 2023, Miguel Barão' __license__ = 'MIT license' __version__ = APP_VERSION diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index d2314c4..12f6717 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -1,5 +1,4 @@ ''' -Learn application. This is the main controller of the application. ''' @@ -34,10 +33,6 @@ class LearnException(Exception): '''Exceptions raised from the LearnApp class''' -class DatabaseUnusableError(LearnException): - '''Exception raised if the database fails in the initialization''' - - # ============================================================================ class LearnApp(): ''' @@ -57,7 +52,7 @@ class LearnApp(): 'number': ..., 'name': ..., 'state': StudentState(), - 'counter': ... + # 'counter': ... }, ... } ''' @@ -157,7 +152,7 @@ class LearnApp(): logger.info(' 0 errors found.') # ------------------------------------------------------------------------ - async def login(self, uid: str, password: str) -> bool: + async def login(self, uid: str, password: str, loop) -> bool: '''user login''' # wait random time to minimize timing attacks @@ -179,7 +174,7 @@ class LearnApp(): if pw_ok: if uid in self.online: logger.warning('User "%s" already logged in', uid) - counter = self.online[uid]['counter'] + counter = self.online[uid]['counter'] # FIXME else: logger.info('User "%s" logged in', uid) counter = 0 @@ -200,7 +195,7 @@ class LearnApp(): 'state': StudentState(uid=uid, state=state, courses=self.courses, deps=self.deps, factory=self.factory), - 'counter': counter + 1, # count simultaneous logins + 'counter': counter + 1, # count simultaneous logins FIXME } else: @@ -231,7 +226,7 @@ class LearnApp(): query = select(Student).where(Student.id == uid) with Session(self._engine) as session: - session.execute(query).scalar_one().password = hashed_pw + session.execute(query).scalar_one().password = str(hashed_pw) session.commit() logger.info('User "%s" changed password', uid) @@ -338,7 +333,6 @@ class LearnApp(): logger.info('User "%s" started topic "%s"', uid, topic) # ------------------------------------------------------------------------ - # ------------------------------------------------------------------------ def _add_missing_topics(self, topics: Iterable[str]) -> None: ''' Fill table 'Topic' with topics from the graph, if new @@ -500,9 +494,9 @@ class LearnApp(): return factory - def get_login_counter(self, uid: str) -> int: - '''login counter''' - return int(self.online[uid]['counter']) + # def get_login_counter(self, uid: str) -> int: + # '''login counter''' + # return int(self.online[uid]['counter']) def get_student_name(self, uid: str) -> str: '''Get the username''' diff --git a/aprendizations/questions.py b/aprendizations/questions.py index 85eef1f..89c7ced 100644 --- a/aprendizations/questions.py +++ b/aprendizations/questions.py @@ -15,7 +15,7 @@ from typing import Any, Dict, NewType import uuid # this project -from aprendizations.tools import run_script_async +from aprendizations.tools import run_script # setup logger for this module logger = logging.getLogger(__name__) @@ -545,7 +545,7 @@ class QuestionTextArea(Question): super().correct() if self['answer'] is not None: # correct answer and parse yaml ouput - out = await run_script_async( + out = await run_script( script=self['correct'], args=self['args'], stdin=self['answer'], @@ -687,7 +687,7 @@ class QFactory(): qdict.setdefault('args', []) qdict.setdefault('stdin', '') script = path.join(qdict['path'], qdict['script']) - out = await run_script_async(script=script, + out = await run_script(script=script, args=qdict['args'], stdin=qdict['stdin']) qdict.update(out) diff --git a/aprendizations/serve.py b/aprendizations/serve.py index a9f1520..14ddb05 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -1,18 +1,16 @@ ''' -Webserver +Tornado Webserver ''' # python standard library import asyncio import base64 -import functools from logging import getLogger import mimetypes from os.path import join, dirname, expanduser import signal import sys -import re from typing import List, Optional, Union import uuid @@ -20,7 +18,7 @@ import uuid import tornado.httpserver import tornado.ioloop import tornado.web -from tornado.escape import to_unicode +from tornado.escape import to_unicode, utf8 # this project from aprendizations.renderer_markdown import md_to_html @@ -32,152 +30,54 @@ from aprendizations import APP_NAME logger = getLogger(__name__) -# ---------------------------------------------------------------------------- -def admin_only(func): - ''' - Decorator used to restrict access to the administrator - ''' - @functools.wraps(func) - def wrapper(self, *args, **kwargs) -> None: - if self.current_user != '0': - raise tornado.web.HTTPError(403) # forbidden - func(self, *args, **kwargs) - return wrapper - - -# ============================================================================ -class WebApplication(tornado.web.Application): - ''' - WebApplication - Tornado Web Server - ''' - def __init__(self, learnapp, debug=False) -> None: - handlers = [ - (r'/login', LoginHandler), - (r'/logout', LogoutHandler), - (r'/change_password', ChangePasswordHandler), - (r'/question', QuestionHandler), # render question - (r'/rankings', RankingsHandler), # rankings table - (r'/topic/(.+)', TopicHandler), # start topic - (r'/file/(.+)', FileHandler), # serve file - (r'/courses', CoursesHandler), # show available courses - (r'/course/(.*)', CourseHandler), # show topics from course - (r'/course2/(.*)', CourseHandler2), # show topics from course FIXME - (r'/', RootHandler), # redirects - ] - settings = { - 'template_path': join(dirname(__file__), 'templates'), - 'static_path': join(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 # ============================================================================ # pylint: disable=abstract-method class BaseHandler(tornado.web.RequestHandler): - ''' - Base handler common to all handlers. - ''' + '''Base handler common to all handlers.''' + + def initialize(self, app): + self.app = app - @property - def learn(self): - '''easier access to learnapp''' - return self.application.learn - def get_current_user(self): '''called on every method decorated with @tornado.web.authenticated''' - user_cookie = self.get_secure_cookie('aprendizations_user') - counter_cookie = self.get_secure_cookie('counter') - if user_cookie is not None: - uid = user_cookie.decode('utf-8') - if counter_cookie is not None: - counter = counter_cookie.decode('utf-8') - if counter == str(self.learn.get_login_counter(uid)): - return uid - return None - - -# ---------------------------------------------------------------------------- -class RankingsHandler(BaseHandler): - ''' - Handles rankings page - ''' - - @tornado.web.authenticated - def get(self) -> None: - ''' - Renders list of students that have answers in this course. - ''' - uid = self.current_user - current_course = self.learn.get_current_course_id(uid) - course_id = self.get_query_argument('course', default=current_course) - rankings = self.learn.get_rankings(uid, course_id) - self.render('rankings.html', - appname=APP_NAME, - uid=uid, - name=self.learn.get_student_name(uid), - rankings=rankings, - course_id=course_id, - course_title=self.learn.get_student_course_title(uid), - # FIXME get from course var - ) + cookie = self.get_secure_cookie('aprendizations_user') + return None if cookie is None else to_unicode(cookie) # ---------------------------------------------------------------------------- class LoginHandler(BaseHandler): - ''' - Handles /login - ''' + '''Handles /login''' def get(self) -> None: - ''' - Render login page - ''' - self.render('login.html', appname=APP_NAME, error='') + '''Login page''' + self.render('login.html', error='') async def post(self): - ''' - Authenticate and redirect to application if successful - ''' + '''Authenticate and redirect to application if successful''' userid = self.get_body_argument('uid') or '' passwd = self.get_body_argument('pw') - match = re.search(r'[0-9]+', userid) # extract number - if match is not None: - userid = match.group(0) # get string with number - if await self.learn.login(userid, passwd): - counter = str(self.learn.get_login_counter(userid)) - self.set_secure_cookie('aprendizations_user', userid) - self.set_secure_cookie('counter', counter) - self.redirect('/') - self.render('login.html', - appname=APP_NAME, - error='Número ou senha incorrectos') + loop = tornado.ioloop.IOLoop.current() + login_ok = await self.app.login(userid, passwd, loop) + if login_ok: + self.set_secure_cookie('aprendizations_user', userid) + self.redirect('/') + else: + self.render('login.html', error='Número ou senha incorrectos') # ---------------------------------------------------------------------------- class LogoutHandler(BaseHandler): - ''' - Handles /logout - ''' + '''Handle /logout''' + @tornado.web.authenticated def get(self) -> None: - ''' - clear cookies and user session - ''' - self.clear_cookie('user') - self.clear_cookie('counter') + '''Clear cookies and user session''' + self.app.logout(self.current_user) # FIXME + self.clear_cookie('aprendizations_user') self.redirect('/') - def on_finish(self) -> None: - self.learn.logout(self.current_user) - # ---------------------------------------------------------------------------- class ChangePasswordHandler(BaseHandler): @@ -191,20 +91,9 @@ class ChangePasswordHandler(BaseHandler): Try to change password and show success/fail status ''' userid = self.current_user - passwd = self.get_body_arguments('new_password')[0] - changed_ok = await self.learn.change_password(userid, passwd) - if changed_ok: - notification = self.render_string( - 'notification.html', - type='success', - msg='A password foi alterada!' - ) - else: - notification = self.render_string( - 'notification.html', - type='danger', - msg='A password não foi alterada!' - ) + passwd = self.get_body_arguments('new_password')[0] # FIXME porque [0]? + ok = await self.app.change_password(userid, passwd) + notification = self.render_string('notification.html', ok=ok) self.write({'msg': to_unicode(notification)}) @@ -213,7 +102,7 @@ class RootHandler(BaseHandler): ''' Handles root / ''' - + @tornado.web.authenticated def get(self) -> None: '''Redirect to main entrypoint''' @@ -225,7 +114,6 @@ class CoursesHandler(BaseHandler): ''' Handles /courses ''' - def set_default_headers(self, *_) -> None: self.set_header('Cache-Control', 'no-cache') @@ -236,8 +124,8 @@ class CoursesHandler(BaseHandler): self.render('courses.html', appname=APP_NAME, uid=uid, - name=self.learn.get_student_name(uid), - courses=self.learn.get_courses(), + name=self.app.get_student_name(uid), + courses=self.app.get_courses(), # courses_progress= ) @@ -254,21 +142,20 @@ class CourseHandler2(BaseHandler): logger.debug('[CourseHandler2] uid="%s", course_id="%s"', uid, course_id) if course_id == '': - course_id = self.learn.get_current_course_id(uid) + course_id = self.app.get_current_course_id(uid) try: - self.learn.start_course(uid, course_id) + self.app.start_course(uid, course_id) except LearnException: self.redirect('/courses') - # print(self.learn.get_course(course_id)) self.render('maintopics-table2.html', appname=APP_NAME, uid=uid, - name=self.learn.get_student_name(uid), - state=self.learn.get_student_state(uid), + name=self.app.get_student_name(uid), + state=self.app.get_student_state(uid), course_id=course_id, - course=self.learn.get_course(course_id) + course=self.app.get_course(course_id) ) # ============================================================================ @@ -287,20 +174,20 @@ class CourseHandler(BaseHandler): logger.debug('[CourseHandler] uid="%s", course_id="%s"', uid, course_id) if course_id == '': - course_id = self.learn.get_current_course_id(uid) + course_id = self.app.get_current_course_id(uid) try: - self.learn.start_course(uid, course_id) + self.app.start_course(uid, course_id) except LearnException: self.redirect('/courses') self.render('maintopics-table.html', appname=APP_NAME, uid=uid, - name=self.learn.get_student_name(uid), - state=self.learn.get_student_state(uid), + name=self.app.get_student_name(uid), + state=self.app.get_student_state(uid), course_id=course_id, - course=self.learn.get_course(course_id) + course=self.app.get_course(course_id) ) @@ -321,15 +208,15 @@ class TopicHandler(BaseHandler): uid = self.current_user logger.debug('[TopicHandler] %s', topic) try: - await self.learn.start_topic(uid, topic) # FIXME GET should not modify state... + await self.app.start_topic(uid, topic) # FIXME GET should not modify state... except KeyError: self.redirect('/topics') self.render('topic.html', appname=APP_NAME, uid=uid, - name=self.learn.get_student_name(uid), - course_id=self.learn.get_current_course_id(uid), + name=self.app.get_student_name(uid), + course_id=self.app.get_current_course_id(uid), ) @@ -345,7 +232,7 @@ class FileHandler(BaseHandler): Serve file from the /public subdirectory of a particular topic ''' uid = self.current_user - public_dir = self.learn.get_current_public_dir(uid) + public_dir = self.app.get_current_public_dir(uid) filepath = expanduser(join(public_dir, filename)) logger.debug('[FileHandler] uid=%s, public_dir=%s, filepath=%s', @@ -391,7 +278,7 @@ class QuestionHandler(BaseHandler): ''' logger.debug('[QuestionHandler]') user = self.current_user - question = await self.learn.get_question(user) + question = await self.app.get_question(user) # show current question if question is not None: @@ -402,7 +289,7 @@ class QuestionHandler(BaseHandler): 'params': { 'type': question['type'], 'question': to_unicode(qhtml), - 'progress': self.learn.get_student_progress(user), + 'progress': self.app.get_student_progress(user), 'tries': question['tries'], } } @@ -432,7 +319,7 @@ class QuestionHandler(BaseHandler): logger.debug('[QuestionHandler] answer=%s', answer) # --- check if browser opened different questions simultaneously - if qid != self.learn.get_current_question_id(user): + if qid != self.app.get_current_question_id(user): logger.warning('User %s desynchronized questions', user) self.write({ 'method': 'invalid', @@ -444,7 +331,7 @@ class QuestionHandler(BaseHandler): return # --- answers are in a list. fix depending on question type - qtype = self.learn.get_student_question_type(user) + qtype = self.app.get_student_question_type(user) ans: Optional[Union[List, str]] if qtype in ('success', 'information', 'info'): ans = None @@ -456,7 +343,7 @@ class QuestionHandler(BaseHandler): ans = answer # --- check answer (nonblocking) and get corrected question and action - question = await self.learn.check_answer(user, ans) + question = await self.app.check_answer(user, ans) # --- build response response = {'method': question['status'], 'params': {}} @@ -470,7 +357,7 @@ class QuestionHandler(BaseHandler): md=md_to_html) response['params'] = { 'type': question['type'], - 'progress': self.learn.get_student_progress(user), + 'progress': self.app.get_student_progress(user), 'comments': to_unicode(comments), 'solution': to_unicode(solution), 'tries': question['tries'], @@ -481,7 +368,7 @@ class QuestionHandler(BaseHandler): md=md_to_html) response['params'] = { 'type': question['type'], - 'progress': self.learn.get_student_progress(user), + 'progress': self.app.get_student_progress(user), 'comments': to_unicode(comments), 'tries': question['tries'], } @@ -493,7 +380,7 @@ class QuestionHandler(BaseHandler): 'solution.html', solution=question['solution'], md=md_to_html) response['params'] = { 'type': question['type'], - 'progress': self.learn.get_student_progress(user), + 'progress': self.app.get_student_progress(user), 'comments': to_unicode(comments), 'solution': to_unicode(solution), 'tries': question['tries'], @@ -505,6 +392,32 @@ class QuestionHandler(BaseHandler): # ---------------------------------------------------------------------------- +class RankingsHandler(BaseHandler): + ''' + Handles rankings page + ''' + + @tornado.web.authenticated + def get(self) -> None: + ''' + Renders list of students that have answers in this course. + ''' + uid = self.current_user + current_course = self.app.get_current_course_id(uid) + course_id = self.get_query_argument('course', default=current_course) + rankings = self.app.get_rankings(uid, course_id) + self.render('rankings.html', + appname=APP_NAME, + uid=uid, + name=self.app.get_student_name(uid), + rankings=rankings, + course_id=course_id, + course_title=self.app.get_student_course_title(uid), + # FIXME get from course var + ) + + +# ---------------------------------------------------------------------------- # Signal handler to catch Ctrl-C and abort server # ---------------------------------------------------------------------------- def signal_handler(*_) -> None: @@ -525,11 +438,29 @@ async def webserver(app, ssl, port: int = 8443, debug: bool = False) -> None: ''' # --- create web application - try: - webapp = WebApplication(app, debug=debug) - except Exception: - logger.critical('Failed to start web application.', exc_info=True) - sys.exit(1) + handlers = [ + (r'/login', LoginHandler, dict(app=app)), + (r'/logout', LogoutHandler, dict(app=app)), + (r'/change_password', ChangePasswordHandler, dict(app=app)), + (r'/question', QuestionHandler, dict(app=app)), # render question + (r'/rankings', RankingsHandler, dict(app=app)), # rankings table + (r'/topic/(.+)', TopicHandler, dict(app=app)), # start topic + (r'/file/(.+)', FileHandler, dict(app=app)), # serve file + (r'/courses', CoursesHandler, dict(app=app)), # show available courses + (r'/course/(.*)', CourseHandler, dict(app=app)), # show topics from course + (r'/course2/(.*)', CourseHandler2, dict(app=app)), # show topics from course FIXME + (r'/', RootHandler, dict(app=app)), # redirects + ] + settings = { + 'template_path': join(dirname(__file__), 'templates'), + 'static_path': join(dirname(__file__), 'static'), + 'static_url_prefix': '/static/', + 'xsrf_cookies': True, + 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), + 'login_url': '/login', + 'debug': debug, + } + webapp = tornado.web.Application(handlers, **settings) logger.info('Web application started (tornado.web.Application)') # --- create tornado http server @@ -538,14 +469,14 @@ async def webserver(app, ssl, port: int = 8443, debug: bool = False) -> None: except ValueError: logger.critical('Certificates cert.pem and privkey.pem not found') sys.exit(1) - logger.debug('HTTP server started') + logger.debug('HTTPS server started') try: httpserver.listen(port) except OSError: logger.critical('Cannot bind port %d. Already in use?', port) sys.exit(1) - logger.info('Webserver listening on %d... (Ctrl-C to stop)', port) + logger.info('Listening on port %d... (Ctrl-C to stop)', port) # --- set signal handler for Control-C signal.signal(signal.SIGINT, signal_handler) diff --git a/aprendizations/templates/login.html b/aprendizations/templates/login.html index f320e16..dec8abd 100644 --- a/aprendizations/templates/login.html +++ b/aprendizations/templates/login.html @@ -25,7 +25,7 @@ - {{appname}} + aprendizations diff --git a/aprendizations/templates/notification.html b/aprendizations/templates/notification.html index 60bee18..0635ce6 100644 --- a/aprendizations/templates/notification.html +++ b/aprendizations/templates/notification.html @@ -1,4 +1,12 @@ - +{% if ok %} + +{% else %} + +{% end %} + diff --git a/aprendizations/tools.py b/aprendizations/tools.py index d40fded..75ad213 100644 --- a/aprendizations/tools.py +++ b/aprendizations/tools.py @@ -4,7 +4,7 @@ import asyncio import logging from os import path # import re -import subprocess +# import subprocess from typing import Any, List # third party libraries @@ -212,36 +212,42 @@ def load_yaml(filename: str, default: Any = None) -> Any: # ---------------------------------------------------------------------------- # Same as above, but asynchronous # ---------------------------------------------------------------------------- -async def run_script_async(script: str, +async def run_script(script: str, args: List[str] = [], stdin: str = '', timeout: int = 2) -> Any: + # normalize args script = path.expanduser(script) + input_bytes = stdin.encode('utf-8') args = [str(a) for a in args] - p = await asyncio.create_subprocess_exec( - script, *args, - stdin=asyncio.subprocess.PIPE, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.DEVNULL, - ) - try: - stdout, _ = await asyncio.wait_for( - p.communicate(input=stdin.encode('utf-8')), - timeout=timeout + p = await asyncio.create_subprocess_exec( + script, *args, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.DEVNULL, ) - except asyncio.TimeoutError: - logger.warning(f'Timeout {timeout}s running script "{script}".') - return - - if p.returncode != 0: - logger.error(f'Return code {p.returncode} running "{script}".') + except FileNotFoundError: + logger.error(f'Can not execute script "{script}": not found.') + except PermissionError: + logger.error(f'Can not execute script "{script}": wrong permissions.') + except OSError: + logger.error(f'Can not execute script "{script}": unknown reason.') else: try: - output = yaml.safe_load(stdout.decode('utf-8', 'ignore')) - except Exception: - logger.error(f'Error parsing yaml output of "{script}"') + stdout, _ = await asyncio.wait_for(p.communicate(input_bytes), timeout) + except asyncio.TimeoutError: + logger.warning(f'Timeout {timeout}s exceeded running "{script}".') + return + + if p.returncode != 0: + logger.error(f'Return code {p.returncode} running "{script}".') else: - return output + try: + output = yaml.safe_load(stdout.decode('utf-8', 'ignore')) + except Exception: + logger.error(f'Error parsing yaml output of "{script}"') + else: + return output diff --git a/mypy.ini b/mypy.ini index 101870a..9e4f0a3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1,6 +1,6 @@ -[mypy] -python_version = 3.10 -plugins = sqlalchemy.ext.mypy.plugin +; [mypy] +; python_version = 3.10 +; plugins = sqlalchemy.ext.mypy.plugin ; [mypy-pygments.*] ; ignore_missing_imports = True diff --git a/setup.py b/setup.py index c90e1b5..2975c5c 100644 --- a/setup.py +++ b/setup.py @@ -18,7 +18,7 @@ setup( url="https://git.xdi.uevora.pt/mjsb/aprendizations.git", packages=find_packages(), include_package_data=True, # install files from MANIFEST.in - python_requires='>=3.9.*', + python_requires='>=3.9', install_requires=[ 'tornado>=6.2', 'mistune>=3.0.0rc4', -- libgit2 0.21.2