Commit a221e180a4d478f42187afb1398f09fde1665634

Authored by Miguel Barão
1 parent a1764bc3
Exists in master and in 1 other branch dev

- 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)
... ...