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
@@ -61,16 +61,19 @@ class QuestionRadio(Question): @@ -61,16 +61,19 @@ class QuestionRadio(Question):
61 type (str) 61 type (str)
62 text (str) 62 text (str)
63 options (list of strings) 63 options (list of strings)
64 - shuffle (bool, default=True)  
65 correct (list of floats) 64 correct (list of floats)
66 discount (bool, default=True) 65 discount (bool, default=True)
67 answer (None or an actual answer) 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 def __init__(self, q): 72 def __init__(self, q):
72 super().__init__(q) 73 super().__init__(q)
73 74
  75 + n = len(self['options'])
  76 +
74 # set defaults if missing 77 # set defaults if missing
75 self.set_defaults({ 78 self.set_defaults({
76 'text': '', 79 'text': '',
@@ -79,23 +82,33 @@ class QuestionRadio(Question): @@ -79,23 +82,33 @@ class QuestionRadio(Question):
79 'discount': True, 82 'discount': True,
80 }) 83 })
81 84
82 - n = len(self['options'])  
83 -  
84 # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] 85 # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0]
85 # correctness levels from 0.0 to 1.0 (no discount here!) 86 # correctness levels from 0.0 to 1.0 (no discount here!)
86 if isinstance(self['correct'], int): 87 if isinstance(self['correct'], int):
87 self['correct'] = [1.0 if x==self['correct'] else 0.0 for x in range(n)] 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 if self['shuffle']: 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 # can return negative values for wrong answers 114 # can return negative values for wrong answers
@@ -122,6 +135,7 @@ class QuestionCheckbox(Question): @@ -122,6 +135,7 @@ class QuestionCheckbox(Question):
122 shuffle (bool, default True) 135 shuffle (bool, default True)
123 correct (list of floats) 136 correct (list of floats)
124 discount (bool, default True) 137 discount (bool, default True)
  138 + choose (int)
125 answer (None or an actual answer) 139 answer (None or an actual answer)
126 ''' 140 '''
127 141
@@ -137,18 +151,30 @@ class QuestionCheckbox(Question): @@ -137,18 +151,30 @@ class QuestionCheckbox(Question):
137 'correct': [0.0] * n, # useful for questionaries 151 'correct': [0.0] * n, # useful for questionaries
138 'shuffle': True, 152 'shuffle': True,
139 'discount': True, 153 'discount': True,
  154 + 'choose': n, # number of options
140 }) 155 })
141 156
142 if len(self['correct']) != n: 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 # generate random permutation, e.g. [2,1,4,0,3] 172 # generate random permutation, e.g. [2,1,4,0,3]
146 # and apply to `options` and `correct` 173 # and apply to `options` and `correct`
147 if self['shuffle']: 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 # can return negative values for wrong answers 180 # can return negative values for wrong answers
@@ -277,7 +303,7 @@ class QuestionNumericInterval(Question): @@ -277,7 +303,7 @@ class QuestionNumericInterval(Question):
277 # answer = locale.atof(self['answer']) 303 # answer = locale.atof(self['answer'])
278 304
279 except ValueError: 305 except ValueError:
280 - self['comments'] = 'A resposta dada não é numérica.' 306 + self['comments'] = 'A resposta não é numérica.'
281 self['grade'] = 0.0 307 self['grade'] = 0.0
282 else: 308 else:
283 self['grade'] = 1.0 if lower <= answer <= upper else 0.0 309 self['grade'] = 1.0 if lower <= answer <= upper else 0.0
@@ -316,7 +342,7 @@ class QuestionTextArea(Question): @@ -316,7 +342,7 @@ class QuestionTextArea(Question):
316 342
317 if self['answer'] is not None: 343 if self['answer'] is not None:
318 # correct answer 344 # correct answer
319 - out = run_script( 345 + out = run_script( # and parse yaml ouput
320 script=self['correct'], 346 script=self['correct'],
321 stdin=self['answer'], 347 stdin=self['answer'],
322 timeout=self['timeout'] 348 timeout=self['timeout']
@@ -326,7 +352,7 @@ class QuestionTextArea(Question): @@ -326,7 +352,7 @@ class QuestionTextArea(Question):
326 self['grade'] = float(out) 352 self['grade'] = float(out)
327 353
328 elif isinstance(out, dict): 354 elif isinstance(out, dict):
329 - self['comments'] = out.get('comments', self['comments']) 355 + self['comments'] = out.get('comments', '')
330 try: 356 try:
331 self['grade'] = float(out['grade']) 357 self['grade'] = float(out['grade'])
332 except ValueError: 358 except ValueError:
@@ -8,7 +8,7 @@ import logging.config @@ -8,7 +8,7 @@ import logging.config
8 import json 8 import json
9 import base64 9 import base64
10 import uuid 10 import uuid
11 -# from mimetypes import guess_type 11 +import mimetypes
12 import signal 12 import signal
13 13
14 # packages 14 # packages
@@ -88,22 +88,63 @@ class LoginHandler(BaseHandler): @@ -88,22 +88,63 @@ class LoginHandler(BaseHandler):
88 class LogoutHandler(BaseHandler): 88 class LogoutHandler(BaseHandler):
89 @tornado.web.authenticated 89 @tornado.web.authenticated
90 def get(self): 90 def get(self):
91 - self.testapp.logout(self.current_user) 91 + # self.testapp.logout(self.current_user)
92 self.clear_cookie('user') 92 self.clear_cookie('user')
93 self.redirect('/') 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 class FileHandler(BaseHandler): 103 class FileHandler(BaseHandler):
  104 + chunk_size = 4 * 1024 * 1024 # serve up to 4 MiB multiple times
  105 +
99 @tornado.web.authenticated 106 @tornado.web.authenticated
100 - def get(self): 107 + async def get(self, filename):
101 uid = self.current_user 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 # if not os.path.isfile(file_location): 150 # if not os.path.isfile(file_location):
@@ -227,15 +268,6 @@ class ReviewHandler(BaseHandler): @@ -227,15 +268,6 @@ class ReviewHandler(BaseHandler):
227 self.render('review.html', t=t, md=md_to_html, templ=self._templates) 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 # FIXME this should be a post in the test with command giveup instead of correct... 272 # FIXME this should be a post in the test with command giveup instead of correct...
241 class GiveupHandler(BaseHandler): 273 class GiveupHandler(BaseHandler):
@@ -363,25 +395,31 @@ def main(): @@ -363,25 +395,31 @@ def main():
363 sys.exit(1) 395 sys.exit(1)
364 396
365 # --- create web application 397 # --- create web application
  398 + logging.info('Starting Web App (tornado)')
366 try: 399 try:
367 webapp = WebApplication(testapp, debug=arg.debug) 400 webapp = WebApplication(testapp, debug=arg.debug)
368 except Exception as e: 401 except Exception as e:
369 - logging.critical('Can\'t start application.') 402 + logging.critical('Failed to start web application.')
370 raise e 403 raise e
371 404
372 # --- create webserver 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 # --- run webserver 418 # --- run webserver
  419 + logging.info('Webserver running... (Ctrl-C to stop)')
381 signal.signal(signal.SIGINT, signal_handler) 420 signal.signal(signal.SIGINT, signal_handler)
382 421
383 try: 422 try:
384 - logging.info('Webserver running... (Ctrl-C to stop)')  
385 tornado.ioloop.IOLoop.current().start() # running... 423 tornado.ioloop.IOLoop.current().start() # running...
386 except Exception: 424 except Exception:
387 logging.critical('Webserver stopped.') 425 logging.critical('Webserver stopped.')
@@ -16,11 +16,11 @@ from pygments.formatters import HtmlFormatter @@ -16,11 +16,11 @@ from pygments.formatters import HtmlFormatter
16 logger = logging.getLogger(__name__) 16 logger = logging.getLogger(__name__)
17 17
18 18
19 -# --------------------------------------------------------------------------- 19 +# -------------------------------------------------------------------------
20 # Markdown to HTML renderer with support for LaTeX equations 20 # Markdown to HTML renderer with support for LaTeX equations
21 # Inline math: $x$ 21 # Inline math: $x$
22 # Block math: $$x$$ or \begin{equation}x\end{equation} 22 # Block math: $$x$$ or \begin{equation}x\end{equation}
23 -# --------------------------------------------------------------------------- 23 +# -------------------------------------------------------------------------
24 class MathBlockGrammar(mistune.BlockGrammar): 24 class MathBlockGrammar(mistune.BlockGrammar):
25 block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL) 25 block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL)
26 latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL) 26 latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL)
@@ -99,19 +99,21 @@ class HighlightRenderer(mistune.Renderer): @@ -99,19 +99,21 @@ class HighlightRenderer(mistune.Renderer):
99 def table(self, header, body): 99 def table(self, header, body):
100 return '<table class="table table-sm"><thead class="thead-light">' + header + '</thead><tbody>' + body + "</tbody></table>" 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 # Pass math through unaltered - mathjax does the rendering in the browser 118 # Pass math through unaltered - mathjax does the rendering in the browser
117 def block_math(self, text): 119 def block_math(self, text):
@@ -126,35 +128,36 @@ class HighlightRenderer(mistune.Renderer): @@ -126,35 +128,36 @@ class HighlightRenderer(mistune.Renderer):
126 128
127 markdown = MarkdownWithMath(HighlightRenderer(escape=True)) # hard_wrap=True to insert <br> on newline 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 return markdown(text) 132 return markdown(text)
131 133
132 # --------------------------------------------------------------------------- 134 # ---------------------------------------------------------------------------
133 # load data from yaml file 135 # load data from yaml file
134 # --------------------------------------------------------------------------- 136 # ---------------------------------------------------------------------------
135 def load_yaml(filename, default=None): 137 def load_yaml(filename, default=None):
  138 + filename = path.expanduser(filename)
136 try: 139 try:
137 - f = open(path.expanduser(filename), 'r', encoding='utf-8') 140 + f = open(filename, 'r', encoding='utf-8')
138 except FileNotFoundError: 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 except PermissionError: 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 except IOError: 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 else: 147 else:
148 with f: 148 with f:
149 try: 149 try:
150 - return yaml.load(f) 150 + default = yaml.load(f)
151 except yaml.YAMLError as e: 151 except yaml.YAMLError as e:
152 mark = e.problem_mark 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 # Runs a script and returns its stdout parsed as yaml, or None on error. 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 def run_script(script, stdin='', timeout=5): 162 def run_script(script, stdin='', timeout=5):
160 script = path.expanduser(script) 163 script = path.expanduser(script)