Commit a221e180a4d478f42187afb1398f09fde1665634
1 parent
a1764bc3
Exists in
master
and in
1 other branch
- fixed mistune to also serve images.
- nice error message when certificates missing. - handle files. - updated checkbox and radio questions to generate reduced set of randomized options.
Showing
3 changed files
with
138 additions
and
71 deletions
Show diff stats
questions.py
| ... | ... | @@ -61,16 +61,19 @@ class QuestionRadio(Question): |
| 61 | 61 | type (str) |
| 62 | 62 | text (str) |
| 63 | 63 | options (list of strings) |
| 64 | - shuffle (bool, default=True) | |
| 65 | 64 | correct (list of floats) |
| 66 | 65 | discount (bool, default=True) |
| 67 | 66 | answer (None or an actual answer) |
| 67 | + shuffle (bool, default=True) | |
| 68 | + choose (int) # only used if shuffle=True | |
| 68 | 69 | ''' |
| 69 | 70 | |
| 70 | 71 | #------------------------------------------------------------------------ |
| 71 | 72 | def __init__(self, q): |
| 72 | 73 | super().__init__(q) |
| 73 | 74 | |
| 75 | + n = len(self['options']) | |
| 76 | + | |
| 74 | 77 | # set defaults if missing |
| 75 | 78 | self.set_defaults({ |
| 76 | 79 | 'text': '', |
| ... | ... | @@ -79,23 +82,33 @@ class QuestionRadio(Question): |
| 79 | 82 | 'discount': True, |
| 80 | 83 | }) |
| 81 | 84 | |
| 82 | - n = len(self['options']) | |
| 83 | - | |
| 84 | 85 | # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] |
| 85 | 86 | # correctness levels from 0.0 to 1.0 (no discount here!) |
| 86 | 87 | if isinstance(self['correct'], int): |
| 87 | 88 | self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] |
| 88 | 89 | |
| 89 | - elif len(self['correct']) != n: | |
| 90 | - logger.error(f'Number of options and correct mismatch in "{self["ref"]}", file "{self["filename"]}".') | |
| 91 | - | |
| 92 | - # generate random permutation, e.g. [2,1,4,0,3] | |
| 93 | - # and apply to `options` and `correct` | |
| 94 | 90 | if self['shuffle']: |
| 95 | - perm = list(range(n)) | |
| 96 | - random.shuffle(perm) | |
| 97 | - self['options'] = [ str(self['options'][i]) for i in perm ] | |
| 98 | - self['correct'] = [ float(self['correct'][i]) for i in perm ] | |
| 91 | + # separate right from wrong options | |
| 92 | + right = [i for i in range(n) if self['correct'][i] == 1] | |
| 93 | + wrong = [i for i in range(n) if self['correct'][i] < 1] | |
| 94 | + | |
| 95 | + self.set_defaults({'choose': 1+len(wrong)}) | |
| 96 | + | |
| 97 | + # choose 1 correct option | |
| 98 | + r = random.choice(right) | |
| 99 | + options = [ self['options'][r] ] | |
| 100 | + correct = [ 1.0 ] | |
| 101 | + | |
| 102 | + # choose remaining wrong options | |
| 103 | + random.shuffle(wrong) | |
| 104 | + nwrong = self['choose']-1 | |
| 105 | + options.extend(self['options'][i] for i in wrong[:nwrong]) | |
| 106 | + correct.extend(self['correct'][i] for i in wrong[:nwrong]) | |
| 107 | + | |
| 108 | + # final shuffle of the options | |
| 109 | + perm = random.sample(range(self['choose']), self['choose']) | |
| 110 | + self['options'] = [ str(options[i]) for i in perm ] | |
| 111 | + self['correct'] = [ float(correct[i]) for i in perm ] | |
| 99 | 112 | |
| 100 | 113 | #------------------------------------------------------------------------ |
| 101 | 114 | # can return negative values for wrong answers |
| ... | ... | @@ -122,6 +135,7 @@ class QuestionCheckbox(Question): |
| 122 | 135 | shuffle (bool, default True) |
| 123 | 136 | correct (list of floats) |
| 124 | 137 | discount (bool, default True) |
| 138 | + choose (int) | |
| 125 | 139 | answer (None or an actual answer) |
| 126 | 140 | ''' |
| 127 | 141 | |
| ... | ... | @@ -137,18 +151,30 @@ class QuestionCheckbox(Question): |
| 137 | 151 | 'correct': [0.0] * n, # useful for questionaries |
| 138 | 152 | 'shuffle': True, |
| 139 | 153 | 'discount': True, |
| 154 | + 'choose': n, # number of options | |
| 140 | 155 | }) |
| 141 | 156 | |
| 142 | 157 | if len(self['correct']) != n: |
| 143 | - logger.error(f'Number of options and correct mismatch in "{self["ref"]}", file "{self["filename"]}".') | |
| 158 | + logger.error(f'Options and correct size mismatch in "{self["ref"]}", file "{self["filename"]}".') | |
| 159 | + | |
| 160 | + # if an option is a list of (right, wrong), pick one | |
| 161 | + # FIXME it's possible that all options are chosen wrong | |
| 162 | + options = [] | |
| 163 | + correct = [] | |
| 164 | + for o,c in zip(self['options'], self['correct']): | |
| 165 | + if isinstance(o, list): | |
| 166 | + r = random.randint(0,1) | |
| 167 | + o = o[r] | |
| 168 | + c = c if r==0 else -c | |
| 169 | + options.append(str(o)) | |
| 170 | + correct.append(float(c)) | |
| 144 | 171 | |
| 145 | 172 | # generate random permutation, e.g. [2,1,4,0,3] |
| 146 | 173 | # and apply to `options` and `correct` |
| 147 | 174 | if self['shuffle']: |
| 148 | - perm = list(range(n)) | |
| 149 | - random.shuffle(perm) | |
| 150 | - self['options'] = [ str(self['options'][i]) for i in perm ] | |
| 151 | - self['correct'] = [ float(self['correct'][i]) for i in perm ] | |
| 175 | + perm = random.sample(range(n), self['choose']) | |
| 176 | + self['options'] = [options[i] for i in perm] | |
| 177 | + self['correct'] = [correct[i] for i in perm] | |
| 152 | 178 | |
| 153 | 179 | #------------------------------------------------------------------------ |
| 154 | 180 | # can return negative values for wrong answers |
| ... | ... | @@ -277,7 +303,7 @@ class QuestionNumericInterval(Question): |
| 277 | 303 | # answer = locale.atof(self['answer']) |
| 278 | 304 | |
| 279 | 305 | except ValueError: |
| 280 | - self['comments'] = 'A resposta dada não é numérica.' | |
| 306 | + self['comments'] = 'A resposta não é numérica.' | |
| 281 | 307 | self['grade'] = 0.0 |
| 282 | 308 | else: |
| 283 | 309 | self['grade'] = 1.0 if lower <= answer <= upper else 0.0 |
| ... | ... | @@ -316,7 +342,7 @@ class QuestionTextArea(Question): |
| 316 | 342 | |
| 317 | 343 | if self['answer'] is not None: |
| 318 | 344 | # correct answer |
| 319 | - out = run_script( | |
| 345 | + out = run_script( # and parse yaml ouput | |
| 320 | 346 | script=self['correct'], |
| 321 | 347 | stdin=self['answer'], |
| 322 | 348 | timeout=self['timeout'] |
| ... | ... | @@ -326,7 +352,7 @@ class QuestionTextArea(Question): |
| 326 | 352 | self['grade'] = float(out) |
| 327 | 353 | |
| 328 | 354 | elif isinstance(out, dict): |
| 329 | - self['comments'] = out.get('comments', self['comments']) | |
| 355 | + self['comments'] = out.get('comments', '') | |
| 330 | 356 | try: |
| 331 | 357 | self['grade'] = float(out['grade']) |
| 332 | 358 | except ValueError: | ... | ... |
serve.py
| ... | ... | @@ -8,7 +8,7 @@ import logging.config |
| 8 | 8 | import json |
| 9 | 9 | import base64 |
| 10 | 10 | import uuid |
| 11 | -# from mimetypes import guess_type | |
| 11 | +import mimetypes | |
| 12 | 12 | import signal |
| 13 | 13 | |
| 14 | 14 | # packages |
| ... | ... | @@ -88,22 +88,63 @@ class LoginHandler(BaseHandler): |
| 88 | 88 | class LogoutHandler(BaseHandler): |
| 89 | 89 | @tornado.web.authenticated |
| 90 | 90 | def get(self): |
| 91 | - self.testapp.logout(self.current_user) | |
| 91 | + # self.testapp.logout(self.current_user) | |
| 92 | 92 | self.clear_cookie('user') |
| 93 | 93 | self.redirect('/') |
| 94 | 94 | |
| 95 | + def on_finish(self): | |
| 96 | + self.testapp.logout(self.current_user) | |
| 95 | 97 | |
| 96 | -# ------------------------------------------------------------------------- | |
| 97 | -# FIXME checkit | |
| 98 | + | |
| 99 | +# ---------------------------------------------------------------------------- | |
| 100 | +# Serves files from the /public subdir of the topics. | |
| 101 | +# Based on https://bhch.github.io/posts/2017/12/serving-large-files-with-tornado-safely-without-blocking/ | |
| 102 | +# ---------------------------------------------------------------------------- | |
| 98 | 103 | class FileHandler(BaseHandler): |
| 104 | + chunk_size = 4 * 1024 * 1024 # serve up to 4 MiB multiple times | |
| 105 | + | |
| 99 | 106 | @tornado.web.authenticated |
| 100 | - def get(self): | |
| 107 | + async def get(self, filename): | |
| 101 | 108 | uid = self.current_user |
| 102 | - qref = self.get_query_argument('ref') | |
| 103 | - qfile = self.get_query_argument('file') | |
| 104 | - print(f'FileHandler: ref={ref}, file={file}') | |
| 109 | + # public_dir = self.learn.get_current_public_dir(uid) | |
| 110 | + filepath = path.expanduser(path.join(public_dir, filename)) | |
| 111 | + | |
| 112 | + try: | |
| 113 | + f = open(filepath, 'rb') | |
| 114 | + except FileNotFoundError: | |
| 115 | + logging.error(f'File not found: {filepath}') | |
| 116 | + except PermissionError: | |
| 117 | + logging.error(f'No permission: {filepath}') | |
| 118 | + else: | |
| 119 | + content_type = mimetypes.guess_type(filename) | |
| 120 | + self.set_header("Content-Type", content_type[0]) | |
| 121 | + | |
| 122 | + # divide the file into chunks and write one chunk at a time, so | |
| 123 | + # that the write does not block the ioloop for very long. | |
| 124 | + with f: | |
| 125 | + chunk = f.read(self.chunk_size) | |
| 126 | + while chunk: | |
| 127 | + try: | |
| 128 | + self.write(chunk) # write the cunk to response | |
| 129 | + await self.flush() # flush the current chunk to socket | |
| 130 | + except iostream.StreamClosedError: | |
| 131 | + break # client closed the connection | |
| 132 | + finally: | |
| 133 | + del chunk | |
| 134 | + await gen.sleep(0.000000001) # 1 nanosecond (hack) | |
| 135 | + # FIXME in the upcomming tornado 5.0 use `await asyncio.sleep(0)` instead | |
| 136 | + chunk = f.read(self.chunk_size) | |
| 137 | +# ------------------------------------------------------------------------- | |
| 138 | +# FIXME checkit | |
| 139 | +# class FileHandler(BaseHandler): | |
| 140 | +# @tornado.web.authenticated | |
| 141 | +# def get(self): | |
| 142 | +# uid = self.current_user | |
| 143 | +# qref = self.get_query_argument('ref') | |
| 144 | +# qfile = self.get_query_argument('file') | |
| 145 | +# print(f'FileHandler: ref={ref}, file={file}') | |
| 105 | 146 | |
| 106 | - self.write(self.testapp.get_file(ref, filename)) | |
| 147 | +# self.write(self.testapp.get_file(ref, filename)) | |
| 107 | 148 | |
| 108 | 149 | |
| 109 | 150 | # if not os.path.isfile(file_location): |
| ... | ... | @@ -227,15 +268,6 @@ class ReviewHandler(BaseHandler): |
| 227 | 268 | self.render('review.html', t=t, md=md_to_html, templ=self._templates) |
| 228 | 269 | |
| 229 | 270 | |
| 230 | -# --- FILE ------------------------------------------------------------- | |
| 231 | -# class FIXME | |
| 232 | -# @cherrypy.expose | |
| 233 | -# @require(name_is('0')) | |
| 234 | -# def absfile(self, name): | |
| 235 | -# filename = path.abspath(path.join(self.app.get_questions_path(), name)) | |
| 236 | -# return cherrypy.lib.static.serve_file(filename) | |
| 237 | - | |
| 238 | - | |
| 239 | 271 | # ------------------------------------------------------------------------- |
| 240 | 272 | # FIXME this should be a post in the test with command giveup instead of correct... |
| 241 | 273 | class GiveupHandler(BaseHandler): |
| ... | ... | @@ -363,25 +395,31 @@ def main(): |
| 363 | 395 | sys.exit(1) |
| 364 | 396 | |
| 365 | 397 | # --- create web application |
| 398 | + logging.info('Starting Web App (tornado)') | |
| 366 | 399 | try: |
| 367 | 400 | webapp = WebApplication(testapp, debug=arg.debug) |
| 368 | 401 | except Exception as e: |
| 369 | - logging.critical('Can\'t start application.') | |
| 402 | + logging.critical('Failed to start web application.') | |
| 370 | 403 | raise e |
| 371 | 404 | |
| 372 | 405 | # --- create webserver |
| 373 | - http_server = tornado.httpserver.HTTPServer(webapp, | |
| 374 | - ssl_options={ | |
| 375 | - "certfile": "certs/cert.crt", | |
| 376 | - "keyfile": "certs/cert.key" | |
| 377 | - }) | |
| 378 | - http_server.listen(8443) | |
| 406 | + try: | |
| 407 | + http_server = tornado.httpserver.HTTPServer(webapp, | |
| 408 | + ssl_options={ | |
| 409 | + "certfile": "certs/cert.pem", | |
| 410 | + "keyfile": "certs/privkey.pem" | |
| 411 | + }) | |
| 412 | + except ValueError: | |
| 413 | + logging.critical('Certificates cert.pem, privkey.pem not found') | |
| 414 | + sys.exit(1) | |
| 415 | + else: | |
| 416 | + http_server.listen(8443) | |
| 379 | 417 | |
| 380 | 418 | # --- run webserver |
| 419 | + logging.info('Webserver running... (Ctrl-C to stop)') | |
| 381 | 420 | signal.signal(signal.SIGINT, signal_handler) |
| 382 | 421 | |
| 383 | 422 | try: |
| 384 | - logging.info('Webserver running... (Ctrl-C to stop)') | |
| 385 | 423 | tornado.ioloop.IOLoop.current().start() # running... |
| 386 | 424 | except Exception: |
| 387 | 425 | logging.critical('Webserver stopped.') | ... | ... |
tools.py
| ... | ... | @@ -16,11 +16,11 @@ from pygments.formatters import HtmlFormatter |
| 16 | 16 | logger = logging.getLogger(__name__) |
| 17 | 17 | |
| 18 | 18 | |
| 19 | -# --------------------------------------------------------------------------- | |
| 19 | +# ------------------------------------------------------------------------- | |
| 20 | 20 | # Markdown to HTML renderer with support for LaTeX equations |
| 21 | 21 | # Inline math: $x$ |
| 22 | 22 | # Block math: $$x$$ or \begin{equation}x\end{equation} |
| 23 | -# --------------------------------------------------------------------------- | |
| 23 | +# ------------------------------------------------------------------------- | |
| 24 | 24 | class MathBlockGrammar(mistune.BlockGrammar): |
| 25 | 25 | block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) |
| 26 | 26 | latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL) |
| ... | ... | @@ -99,19 +99,21 @@ class HighlightRenderer(mistune.Renderer): |
| 99 | 99 | def table(self, header, body): |
| 100 | 100 | return '<table class="table table-sm"><thead class="thead-light">' + header + '</thead><tbody>' + body + "</tbody></table>" |
| 101 | 101 | |
| 102 | - # def image(self, src, title, text): | |
| 103 | - # if src.startswith('javascript:'): | |
| 104 | - # src = '' | |
| 105 | - # text = mistune.escape(text, quote=True) | |
| 106 | - # if title: | |
| 107 | - # title = mistune.escape(title, quote=True) | |
| 108 | - # html = '<img class="img-responsive center-block" src="%s" alt="%s" title="%s"' % (src, text, title) | |
| 109 | - # else: | |
| 110 | - # html = '<img class="img-responsive center-block" src="%s" alt="%s"' % (src, text) | |
| 111 | - # if self.options.get('use_xhtml'): | |
| 112 | - # return '%s />' % html | |
| 113 | - # return '%s>' % html | |
| 102 | + def image(self, src, title, alt): | |
| 103 | + alt = mistune.escape(alt, quote=True) | |
| 104 | + title = mistune.escape(title or '', quote=True) | |
| 105 | + if title: | |
| 106 | + caption = f'<figcaption class="figure-caption">{title}</figcaption>' | |
| 107 | + else: | |
| 108 | + caption = '' | |
| 114 | 109 | |
| 110 | + return f''' | |
| 111 | + <figure class="figure"> | |
| 112 | + <img src="/file/{src}" class="figure-img img-fluid rounded" alt="{alt}" title="{title}"> | |
| 113 | + {caption} | |
| 114 | + </figure> | |
| 115 | + ''' | |
| 116 | + # return f'<img src="/file/{src}" class="img-fluid mx-auto d-block" alt="{alt}" title="{title}">' | |
| 115 | 117 | |
| 116 | 118 | # Pass math through unaltered - mathjax does the rendering in the browser |
| 117 | 119 | def block_math(self, text): |
| ... | ... | @@ -126,35 +128,36 @@ class HighlightRenderer(mistune.Renderer): |
| 126 | 128 | |
| 127 | 129 | markdown = MarkdownWithMath(HighlightRenderer(escape=True)) # hard_wrap=True to insert <br> on newline |
| 128 | 130 | |
| 129 | -def md_to_html(text): | |
| 131 | +def md_to_html(text, q=None): | |
| 130 | 132 | return markdown(text) |
| 131 | 133 | |
| 132 | 134 | # --------------------------------------------------------------------------- |
| 133 | 135 | # load data from yaml file |
| 134 | 136 | # --------------------------------------------------------------------------- |
| 135 | 137 | def load_yaml(filename, default=None): |
| 138 | + filename = path.expanduser(filename) | |
| 136 | 139 | try: |
| 137 | - f = open(path.expanduser(filename), 'r', encoding='utf-8') | |
| 140 | + f = open(filename, 'r', encoding='utf-8') | |
| 138 | 141 | except FileNotFoundError: |
| 139 | - logger.error(f'Can\'t open "{script}": not found.') | |
| 140 | - return default | |
| 142 | + logger.error(f'Can\'t open "{filename}": not found') | |
| 141 | 143 | except PermissionError: |
| 142 | - logger.error(f'Can\'t open "{script}": no permission.') | |
| 143 | - return default | |
| 144 | + logger.error(f'Can\'t open "{filename}": no permission') | |
| 144 | 145 | except IOError: |
| 145 | - logger.error(f'Can\'t open file "{filename}".') | |
| 146 | - return default | |
| 146 | + logger.error(f'Can\'t open file "{filename}"') | |
| 147 | 147 | else: |
| 148 | 148 | with f: |
| 149 | 149 | try: |
| 150 | - return yaml.load(f) | |
| 150 | + default = yaml.load(f) | |
| 151 | 151 | except yaml.YAMLError as e: |
| 152 | 152 | mark = e.problem_mark |
| 153 | - logger.error(f'In YAML file "{filename}" near line {mark.line}, column {mark.column+1}.') | |
| 154 | - return default | |
| 153 | + logger.error(f'In YAML file "{filename}" near line {mark.line}, column {mark.column+1}') | |
| 154 | + finally: | |
| 155 | + return default | |
| 155 | 156 | |
| 156 | 157 | # --------------------------------------------------------------------------- |
| 157 | 158 | # Runs a script and returns its stdout parsed as yaml, or None on error. |
| 159 | +# The script is run in another process but this function blocks waiting | |
| 160 | +# for its termination. | |
| 158 | 161 | # --------------------------------------------------------------------------- |
| 159 | 162 | def run_script(script, stdin='', timeout=5): |
| 160 | 163 | script = path.expanduser(script) | ... | ... |