diff --git a/app.py b/app.py index a66c256..e84956b 100644 --- a/app.py +++ b/app.py @@ -1,10 +1,10 @@ -# base packages +# python standard libraries from os import path import logging from contextlib import contextmanager # `with` statement in db sessions -# installed packages +# user installed packages import bcrypt from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker, scoped_session @@ -54,7 +54,7 @@ class App(object): # connect to database and check registered students dbfile = path.expanduser(self.testfactory['database']) engine = create_engine(f'sqlite:///{dbfile}', echo=False) - self.Session = scoped_session(sessionmaker(bind=engine)) + self.Session = scoped_session(sessionmaker(bind=engine)) # FIXME not scoped in tornado try: with self.db_session() as s: @@ -213,7 +213,7 @@ class App(object): # --- helpers (getters) def get_student_name(self, uid): return self.online[uid]['student']['name'] - def get_test(self, uid, default=None): + def get_student_test(self, uid, default=None): return self.online[uid].get('test', default) def get_questions_path(self): return self.testfactory['questions_dir'] diff --git a/demo/questions/images/flag-es.svg b/demo/questions/images/flag-es.svg deleted file mode 100644 index fba167d..0000000 --- a/demo/questions/images/flag-es.svg +++ /dev/null @@ -1,406 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/demo/questions/images/flag-fr.svg b/demo/questions/images/flag-fr.svg deleted file mode 100644 index 0baf7f3..0000000 --- a/demo/questions/images/flag-fr.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/demo/questions/images/flag-pt.svg b/demo/questions/images/flag-pt.svg deleted file mode 100644 index 5c19329..0000000 --- a/demo/questions/images/flag-pt.svg +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/demo/questions/images/planets.png b/demo/questions/images/planets.png deleted file mode 100644 index 69845f4..0000000 Binary files a/demo/questions/images/planets.png and /dev/null differ diff --git a/demo/questions/public/flag-es.svg b/demo/questions/public/flag-es.svg new file mode 100644 index 0000000..fba167d --- /dev/null +++ b/demo/questions/public/flag-es.svg @@ -0,0 +1,406 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/questions/public/flag-fr.svg b/demo/questions/public/flag-fr.svg new file mode 100644 index 0000000..0baf7f3 --- /dev/null +++ b/demo/questions/public/flag-fr.svg @@ -0,0 +1 @@ + diff --git a/demo/questions/public/flag-pt.svg b/demo/questions/public/flag-pt.svg new file mode 100644 index 0000000..5c19329 --- /dev/null +++ b/demo/questions/public/flag-pt.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/demo/questions/public/planets.png b/demo/questions/public/planets.png new file mode 100644 index 0000000..69845f4 Binary files /dev/null and b/demo/questions/public/planets.png differ diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml index 61f3fe0..260bc46 100644 --- a/demo/questions/questions-tutorial.yaml +++ b/demo/questions/questions-tutorial.yaml @@ -1,3 +1,5 @@ +--- +# ---------------------------------------------------------------------------- - type: information ref: tut-test title: Configuração do teste @@ -378,9 +380,13 @@ ref: tut-alert title: Texto informativo (perigo) text: | - Texto importante (perigo!). Não conta para avaliação. + Texto importante. Não conta para avaliação. + + ![planetas](planets.png "Planetas do Sistema Solar") - ![imagem](image.jpg "Título da imagem") + As imagens podem ser adicionadas usando a notação standard em markdown. Há duas possibilidads: - As imagens ainda não estão a funcionar. + - Imagens inline: não têm título definido e podem ser incluídas no meio de uma linha de texto usando`![alt text](image.jpg)`. + - Imagens centradas e com título: `![alt text](image.jpg "Título da imagem")`. O título aprece por baixo da imagem. O título pode ser uma string vazia. +# ---------------------------------------------------------------------------- diff --git a/demo/test-tutorial.yaml b/demo/test-tutorial.yaml index 40fe6d8..6c491a1 100644 --- a/demo/test-tutorial.yaml +++ b/demo/test-tutorial.yaml @@ -31,13 +31,13 @@ show_hints: True # Base path applied to the questions files and all the scripts # including question generators and correctors. # Either absolute path or relative to current directory can be used. -questions_dir: demo/questions +questions_dir: demo # (optional) List of files containing questions in yaml format. # Selected questions will be obtained from these files. # If undefined, all yaml files in questions_dir are loaded (not recommended). files: - - questions-tutorial.yaml + - questions/questions-tutorial.yaml # This is the list of questions that will make up the test. # The order is preserved. diff --git a/questionfactory.py b/questionfactory.py index 4214fb7..10f463a 100644 --- a/questionfactory.py +++ b/questionfactory.py @@ -26,18 +26,14 @@ # QuestionTextArea - corrected by an external program # QuestionNumericInterval - line of text parsed as a float -# base +# python standard library from os import path from tools import load_yaml, run_script - import logging - - - +# this project from questions import QuestionRadio, QuestionCheckbox, QuestionText, QuestionTextRegex, QuestionNumericInterval, QuestionTextArea, QuestionInformation - # setup logger for this module logger = logging.getLogger(__name__) diff --git a/questions.py b/questions.py index 5ac8d16..0b0e479 100644 --- a/questions.py +++ b/questions.py @@ -1,11 +1,11 @@ -# base +# python standard library import random import re from os import path import logging -# packages +# user installed libraries import yaml # this project @@ -41,7 +41,7 @@ class Question(dict): 'files': {}, }) - # FIXME unused. does childs need do override this? + # FIXME unused. do childs need do override this? # def updateAnswer(answer=None): # self['answer'] = answer diff --git a/serve.py b/serve.py index 6562bf1..fd160ac 100755 --- a/serve.py +++ b/serve.py @@ -31,7 +31,7 @@ class WebApplication(tornado.web.Application): (r'/test', TestHandler), (r'/review', ReviewHandler), (r'/admin', AdminHandler), - (r'/file/(.+)', FileHandler), # FIXME + (r'/file', FileHandler), # FIXME (r'/', RootHandler), # TODO multiple tests ] @@ -70,14 +70,16 @@ class LoginHandler(BaseHandler): def get(self): self.render('login.html', error='') - async def post(self): + # async + 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') - loop = asyncio.get_event_loop() - login_ok = await loop.run_in_executor(None, self.testapp.login, uid, pw) + # loop = asyncio.get_event_loop() + # login_ok = await loop.run_in_executor(None, self.testapp.login, uid, pw) + login_ok = self.testapp.login(uid, pw) if login_ok: self.set_secure_cookie("user", str(uid), expires_days=30) @@ -103,81 +105,46 @@ class LogoutHandler(BaseHandler): # Based on https://bhch.github.io/posts/2017/12/serving-large-files-with-tornado-safely-without-blocking/ # ---------------------------------------------------------------------------- class FileHandler(BaseHandler): - chunk_size = 4 * 1024 * 1024 # serve up to 4 MiB multiple times + chunk_size = 1024 * 1024 # serve up to 1 MiB multiple times @tornado.web.authenticated - def get(self, filename): + async def get(self): uid = self.current_user - # ref = self.get_query_argument('ref') - # print(ref) - # questions_path = self.testapp.get_questions_path() - # p = path.join(questions_path, ref, 'public', filename) - # print(p) - logging.error(f'{uid} requested file but FileHandler is not working!!!') - self.write('image') - - - # # public_dir = self.learn.get_current_public_dir(uid) - # filepath = path.expanduser(path.join(public_dir, filename)) - - # try: - # f = open(filepath, 'rb') - # except FileNotFoundError: - # logging.error(f'File not found: {filepath}') - # except PermissionError: - # logging.error(f'No permission: {filepath}') - # else: - # content_type = mimetypes.guess_type(filename) - # self.set_header("Content-Type", content_type[0]) - - # # divide the file into chunks and write one chunk at a time, so - # # that the write does not block the ioloop for very long. - # with f: - # chunk = f.read(self.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: - # break # client closed the connection - # finally: - # del chunk - # await gen.sleep(0.000000001) # 1 nanosecond (hack) - # # FIXME in the upcomming tornado 5.0 use `await asyncio.sleep(0)` instead - # chunk = f.read(self.chunk_size) -# ------------------------------------------------------------------------- -# 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() + ref = self.get_query_argument('ref', None) + image = self.get_query_argument('image', None) + + t = self.testapp.get_student_test(uid) + if t is not None: + for q in t['questions']: + if q['ref'] == ref: + filepath = path.join(q['path'], 'public', image) + + try: + f = open(filepath, 'rb') + except FileNotFoundError: + logging.error(f'File not found: {filepath}') + except PermissionError: + logging.error(f'No permission: {filepath}') + else: + content_type = mimetypes.guess_type(image) + self.set_header("Content-Type", content_type[0]) + + # divide the file into chunks and write one chunk at a time, so + # that the write does not block the ioloop for very long. + with f: + chunk = f.read(self.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: + break # client closed the connection + finally: + del chunk + await asyncio.sleep(0) + chunk = f.read(self.chunk_size) + + raise tornado.web.HTTPError(status_code=404) # ------------------------------------------------------------------------- @@ -204,18 +171,19 @@ class TestHandler(BaseHandler): @tornado.web.authenticated def get(self): uid = self.current_user - t = self.testapp.get_test(uid) or self.testapp.generate_test(uid) + t = self.testapp.get_student_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 - async def post(self): + # async + 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) + t = self.testapp.get_student_test(uid) ans = {} for i, q in enumerate(t['questions']): qid = str(i) # question id @@ -232,8 +200,9 @@ class TestHandler(BaseHandler): 'numeric-interval'): ans[i] = ans[i][0] - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, self.testapp.correct_test, uid, ans) + # loop = asyncio.get_event_loop() + # await loop.run_in_executor(None, self.testapp.correct_test, uid, ans) + self.testapp.correct_test(uid, ans) self.testapp.logout(uid) self.clear_cookie('user') diff --git a/templates/test.html b/templates/test.html index 7c70682..4374f29 100644 --- a/templates/test.html +++ b/templates/test.html @@ -93,7 +93,7 @@
{% for i, q in enumerate(t['questions']) %} - {% module Template(templ[q['type']], i=i, q=q, md=md, show_ref=t['show_ref']) %} + {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), show_ref=t['show_ref']) %} {% end %}
diff --git a/test.py b/test.py index df1ef64..140752f 100644 --- a/test.py +++ b/test.py @@ -1,5 +1,5 @@ -# base +# python standard library from os import path, listdir import sys, fnmatch import random @@ -7,7 +7,7 @@ from datetime import datetime import json import logging -# project +# this project import questionfactory as questions # Logger configuration @@ -37,7 +37,7 @@ class TestFactory(dict): if conf['review']: logger.info('Review mode. No questions loaded.') else: - # loads question_factory + # loads yaml files to question_factory self.question_factory = questions.QuestionFactory() self.question_factory.load_files(files=self['files'], questions_dir=self['questions_dir']) @@ -123,7 +123,7 @@ class TestFactory(dict): # --- defaults for optional keys self.setdefault('title', '') - self.setdefault('show_hints', False) # FIXME not implemented yet + # self.setdefault('show_hints', False) # FIXME not implemented yet self.setdefault('show_points', False) self.setdefault('scale_points', True) self.setdefault('scale_max', 20.0) @@ -176,7 +176,7 @@ class TestFactory(dict): 'answers_dir': self['answers_dir'], # FIXME which ones are required? - 'show_hints': self['show_hints'], + # 'show_hints': self['show_hints'], 'show_points': self['show_points'], 'show_ref': self['show_ref'], 'debug': self['debug'], # required by template test.html @@ -245,6 +245,5 @@ class Test(dict): # ----------------------------------------------------------------------- def save_json(self, filepath): with open(path.expanduser(filepath), 'w') as f: - json.dump(self, f, indent=2, default=str) - # HACK default=str is required for datetime objects + json.dump(self, f, indent=2, default=str) # HACK default=str required for datetime objects logger.info(f'Student {self["student"]["number"]}: saved JSON file.') diff --git a/tools.py b/tools.py index 9e935cd..bca6d09 100644 --- a/tools.py +++ b/tools.py @@ -1,11 +1,11 @@ -# builtin +# python standard library from os import path import subprocess import logging import re -# packages +# user installed libraries import yaml import mistune from pygments import highlight @@ -87,6 +87,10 @@ class MarkdownWithMath(mistune.Markdown): class HighlightRenderer(mistune.Renderer): + def __init__(self, qref='.'): + super().__init__(escape=True) + self.qref = qref + def block_code(self, code, lang='text'): try: lexer = get_lexer_by_name(lang, stripall=False) @@ -101,19 +105,25 @@ class HighlightRenderer(mistune.Renderer): def image(self, src, title, alt): alt = mistune.escape(alt, quote=True) - title = mistune.escape(title or '', quote=True) - if title: - caption = f'
{title}
' - else: - caption = '' + if title is not None: + if title: # not empty string, show as caption + title = mistune.escape(title, quote=True) + caption = f'
{title}
' + else: # title is an empty string, show as centered figure + caption = '' + + return f''' +
+
+ {alt} + {caption} +
+
+ ''' + + else: # title indefined, show as inline image + return f'{alt}' - return f''' -
- {alt} - {caption} -
- ''' - # return f'{alt}' # Pass math through unaltered - mathjax does the rendering in the browser def block_math(self, text): @@ -126,10 +136,8 @@ class HighlightRenderer(mistune.Renderer): return fr'$$$ {text} $$$' -markdown = MarkdownWithMath(HighlightRenderer(escape=True)) # hard_wrap=True to insert
on newline - -def md_to_html(text): - return markdown(text) +def md_to_html(qref='.'): + return MarkdownWithMath(HighlightRenderer(qref=qref)) # --------------------------------------------------------------------------- # load data from yaml file -- libgit2 0.21.2