diff --git a/questions.py b/questions.py index e07c47d..4bf44a2 100644 --- a/questions.py +++ b/questions.py @@ -61,16 +61,19 @@ class QuestionRadio(Question): type (str) text (str) options (list of strings) - shuffle (bool, default=True) correct (list of floats) discount (bool, default=True) answer (None or an actual answer) + shuffle (bool, default=True) + choose (int) # only used if shuffle=True ''' #------------------------------------------------------------------------ def __init__(self, q): super().__init__(q) + n = len(self['options']) + # set defaults if missing self.set_defaults({ 'text': '', @@ -79,23 +82,33 @@ class QuestionRadio(Question): 'discount': True, }) - n = len(self['options']) - # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] # correctness levels from 0.0 to 1.0 (no discount here!) if isinstance(self['correct'], int): self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] - elif len(self['correct']) != n: - logger.error(f'Number of options and correct mismatch in "{self["ref"]}", file "{self["filename"]}".') - - # generate random permutation, e.g. [2,1,4,0,3] - # and apply to `options` and `correct` if self['shuffle']: - perm = list(range(n)) - random.shuffle(perm) - self['options'] = [ str(self['options'][i]) for i in perm ] - self['correct'] = [ float(self['correct'][i]) for i in perm ] + # separate right from wrong options + right = [i for i in range(n) if self['correct'][i] == 1] + wrong = [i for i in range(n) if self['correct'][i] < 1] + + self.set_defaults({'choose': 1+len(wrong)}) + + # choose 1 correct option + r = random.choice(right) + options = [ self['options'][r] ] + correct = [ 1.0 ] + + # choose remaining wrong options + random.shuffle(wrong) + nwrong = self['choose']-1 + options.extend(self['options'][i] for i in wrong[:nwrong]) + correct.extend(self['correct'][i] for i in wrong[:nwrong]) + + # final shuffle of the options + perm = random.sample(range(self['choose']), self['choose']) + self['options'] = [ str(options[i]) for i in perm ] + self['correct'] = [ float(correct[i]) for i in perm ] #------------------------------------------------------------------------ # can return negative values for wrong answers @@ -122,6 +135,7 @@ class QuestionCheckbox(Question): shuffle (bool, default True) correct (list of floats) discount (bool, default True) + choose (int) answer (None or an actual answer) ''' @@ -137,18 +151,30 @@ class QuestionCheckbox(Question): 'correct': [0.0] * n, # useful for questionaries 'shuffle': True, 'discount': True, + 'choose': n, # number of options }) if len(self['correct']) != n: - logger.error(f'Number of options and correct mismatch in "{self["ref"]}", file "{self["filename"]}".') + logger.error(f'Options and correct size mismatch in "{self["ref"]}", file "{self["filename"]}".') + + # if an option is a list of (right, wrong), pick one + # FIXME it's possible that all options are chosen wrong + options = [] + correct = [] + for o,c in zip(self['options'], self['correct']): + if isinstance(o, list): + r = random.randint(0,1) + o = o[r] + c = c if r==0 else -c + options.append(str(o)) + correct.append(float(c)) # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self['shuffle']: - perm = list(range(n)) - random.shuffle(perm) - self['options'] = [ str(self['options'][i]) for i in perm ] - self['correct'] = [ float(self['correct'][i]) for i in perm ] + perm = random.sample(range(n), self['choose']) + self['options'] = [options[i] for i in perm] + self['correct'] = [correct[i] for i in perm] #------------------------------------------------------------------------ # can return negative values for wrong answers @@ -277,7 +303,7 @@ class QuestionNumericInterval(Question): # answer = locale.atof(self['answer']) except ValueError: - self['comments'] = 'A resposta dada não é numérica.' + self['comments'] = 'A resposta não é numérica.' self['grade'] = 0.0 else: self['grade'] = 1.0 if lower <= answer <= upper else 0.0 @@ -316,7 +342,7 @@ class QuestionTextArea(Question): if self['answer'] is not None: # correct answer - out = run_script( + out = run_script( # and parse yaml ouput script=self['correct'], stdin=self['answer'], timeout=self['timeout'] @@ -326,7 +352,7 @@ class QuestionTextArea(Question): self['grade'] = float(out) elif isinstance(out, dict): - self['comments'] = out.get('comments', self['comments']) + self['comments'] = out.get('comments', '') try: self['grade'] = float(out['grade']) except ValueError: diff --git a/serve.py b/serve.py index e320d84..6298db1 100755 --- a/serve.py +++ b/serve.py @@ -8,7 +8,7 @@ import logging.config import json import base64 import uuid -# from mimetypes import guess_type +import mimetypes import signal # packages @@ -88,22 +88,63 @@ class LoginHandler(BaseHandler): class LogoutHandler(BaseHandler): @tornado.web.authenticated def get(self): - self.testapp.logout(self.current_user) + # self.testapp.logout(self.current_user) self.clear_cookie('user') self.redirect('/') + def on_finish(self): + self.testapp.logout(self.current_user) -# ------------------------------------------------------------------------- -# FIXME checkit + +# ---------------------------------------------------------------------------- +# 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): + chunk_size = 4 * 1024 * 1024 # serve up to 4 MiB multiple times + @tornado.web.authenticated - def get(self): + async def get(self, filename): uid = self.current_user - qref = self.get_query_argument('ref') - qfile = self.get_query_argument('file') - print(f'FileHandler: ref={ref}, file={file}') + # 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)) +# self.write(self.testapp.get_file(ref, filename)) # if not os.path.isfile(file_location): @@ -227,15 +268,6 @@ class ReviewHandler(BaseHandler): self.render('review.html', t=t, md=md_to_html, templ=self._templates) -# --- FILE ------------------------------------------------------------- -# class FIXME -# @cherrypy.expose -# @require(name_is('0')) -# def absfile(self, name): -# filename = path.abspath(path.join(self.app.get_questions_path(), name)) -# return cherrypy.lib.static.serve_file(filename) - - # ------------------------------------------------------------------------- # FIXME this should be a post in the test with command giveup instead of correct... class GiveupHandler(BaseHandler): @@ -363,25 +395,31 @@ def main(): sys.exit(1) # --- create web application + logging.info('Starting Web App (tornado)') try: webapp = WebApplication(testapp, debug=arg.debug) except Exception as e: - logging.critical('Can\'t start application.') + logging.critical('Failed to start web application.') raise e # --- create webserver - http_server = tornado.httpserver.HTTPServer(webapp, - ssl_options={ - "certfile": "certs/cert.crt", - "keyfile": "certs/cert.key" - }) - http_server.listen(8443) + try: + http_server = tornado.httpserver.HTTPServer(webapp, + ssl_options={ + "certfile": "certs/cert.pem", + "keyfile": "certs/privkey.pem" + }) + except ValueError: + logging.critical('Certificates cert.pem, privkey.pem not found') + sys.exit(1) + else: + http_server.listen(8443) # --- run webserver + logging.info('Webserver running... (Ctrl-C to stop)') signal.signal(signal.SIGINT, signal_handler) try: - logging.info('Webserver running... (Ctrl-C to stop)') tornado.ioloop.IOLoop.current().start() # running... except Exception: logging.critical('Webserver stopped.') diff --git a/tools.py b/tools.py index 7d63250..5db1547 100644 --- a/tools.py +++ b/tools.py @@ -16,11 +16,11 @@ from pygments.formatters import HtmlFormatter logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- +# ------------------------------------------------------------------------- # Markdown to HTML renderer with support for LaTeX equations # Inline math: $x$ # Block math: $$x$$ or \begin{equation}x\end{equation} -# --------------------------------------------------------------------------- +# ------------------------------------------------------------------------- class MathBlockGrammar(mistune.BlockGrammar): block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL) @@ -99,19 +99,21 @@ class HighlightRenderer(mistune.Renderer): def table(self, header, body): return '