Commit 3aa7e128221d42eafb5d60e19c2dd19a235c5bb1

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

- usable version!

- mistune for markdown rendering.
- change password is working.
- other fixes.
BUGS.md
1 1  
2 2 BUGS:
3 3  
4   -- change password in maintopics.html, falta menu para lançar modal
  4 +- indentação da primeira linha de código não funciona.
  5 +- submeter questoes radio, da erro se nao escolher nenhuma opção.
  6 +- gravar evolucao na bd no final de cada topico.
  7 +- servir imagens/ficheiros.
  8 +- topicos virtuais nao deveriam aparecer. na construção da árvore os sucessores seriam ligados directamente aos predecessores.
  9 +
5 10  
6 11 - reportar comentarios após submeter.
7 12 - cada topico tem uma pagina begin e uma end
... ... @@ -26,6 +31,8 @@ TODO:
26 31  
27 32 FIXED:
28 33  
  34 +- markdown com o mistune.
  35 +- change password in maintopics.html, falta menu para lançar modal
29 36 - ver documentacao de migracao para networkx 2.0 https://networkx.github.io/documentation/stable/release/migration_guide_from_1.x_to_2.0.html
30 37 - script para adicionar users/reset passwords.
31 38 - os topicos locked devem estar inactivos no sidebar.
... ...
demo/demo.yaml
... ... @@ -19,6 +19,6 @@ topics:
19 19  
20 20 # topic with one dependency
21 21 solar_system:
22   - name: Solar system
  22 + name: Sistema solar
23 23 deps:
24 24 - math
... ...
knowledge.py
... ... @@ -37,6 +37,7 @@ class StudentKnowledge(object):
37 37 # compute recommended sequence of topics ['a', 'b', ...]
38 38 self.topic_sequence = list(nx.topological_sort(self.deps))
39 39  
  40 + self.unlock_topics()
40 41  
41 42 # ------------------------------------------------------------------------
42 43 # Unlock topics whose dependencies are satisfied (> min_level)
... ... @@ -90,7 +91,6 @@ class StudentKnowledge(object):
90 91  
91 92 if not topic:
92 93 topic = self.recommended_topic()
93   - print(f'recommended {topic}')
94 94  
95 95 self.current_topic = topic
96 96 logger.info(f'Topic set to "{topic}"')
... ... @@ -112,10 +112,17 @@ class StudentKnowledge(object):
112 112 logger.debug('StudentKnowledge.check_answer()')
113 113  
114 114 q = self.current_question
  115 +
  116 + # answers are returned from tornado in a list
  117 + if q['type'] in ('success', 'information', 'info'): # FIXME danger...
  118 + q['answer'] = None
  119 + elif q['type'] != 'checkbox':
  120 + q['answer'] = answer[0]
  121 + else:
  122 + q['answer'] = answer
  123 +
115 124 q['finish_time'] = datetime.now()
116   - q['answer'] = answer
117 125 grade = q.correct()
118   -
119 126 logger.debug(f'Grade = {grade:.2} ({q["ref"]})')
120 127  
121 128 # if answer is correct, get next question
... ... @@ -124,17 +131,18 @@ class StudentKnowledge(object):
124 131 try:
125 132 self.current_question = self.questions.pop(0) # FIXME empty?
126 133 except IndexError:
  134 + # finished topic, no more questions
127 135 self.current_question = None
128 136 self.state[self.current_topic] = {
129 137 'level': 1.0,
130 138 'date': datetime.now()
131 139 }
  140 + self.unlock_topics()
132 141 else:
133 142 self.current_question['start_time'] = datetime.now()
134 143  
135 144 # if answer is wrong, keep same question and add a similar one at the end
136 145 else:
137   - print('failed')
138 146 factory = self.deps.node[self.current_topic]['factory']
139 147 self.questions.append(factory[q['ref']].generate())
140 148  
... ...
serve.py
... ... @@ -19,7 +19,7 @@ from tornado import template, gen
19 19  
20 20 # this project
21 21 from learnapp import LearnApp
22   -from tools import load_yaml, md
  22 +from tools import load_yaml, md_to_html
23 23  
24 24  
25 25 # ============================================================================
... ... @@ -30,11 +30,11 @@ class WebApplication(tornado.web.Application):
30 30 handlers = [
31 31 (r'/login', LoginHandler),
32 32 (r'/logout', LogoutHandler),
33   - # (r'/change_password', ChangePasswordHandler),
  33 + (r'/change_password', ChangePasswordHandler),
34 34 (r'/question', QuestionHandler), # each question
35 35 (r'/topic/(.+)', TopicHandler), # page for doing a topic
36 36 (r'/', RootHandler), # show list of topics
37   - # (r'/(.+)', FileHandler),
  37 + # (r'/file/(.+)', FileHandler),
38 38 ]
39 39 settings = {
40 40 'template_path': path.join(path.dirname(__file__), 'templates'),
... ... @@ -174,13 +174,9 @@ class QuestionHandler(BaseHandler):
174 174 }
175 175  
176 176 def new_question(self, user):
177   - logger.debug(f'new_question({user})')
178   - # state = self.learn.get_student_state(user) # [{ref, name, level},...]
179   - # current_topic = self.learn.get_student_topic(user) # str
180   -
181 177 question = self.learn.get_student_question(user) # Question
182 178 template = self.templates[question['type']]
183   - question_html = self.render_string(template, question=question, md=md)
  179 + question_html = self.render_string(template, question=question, md=md_to_html)
184 180  
185 181 return {
186 182 'method': 'new_question',
... ... @@ -191,7 +187,6 @@ class QuestionHandler(BaseHandler):
191 187 }
192 188  
193 189 def wrong_answer(self, user):
194   - logger.debug(f'wrong_answer({user})')
195 190 progress = self.learn.get_student_progress(user) # in the current topic
196 191 return {
197 192 'method': 'shake',
... ... @@ -200,17 +195,12 @@ class QuestionHandler(BaseHandler):
200 195 }
201 196 }
202 197  
203   - def finished_topic(self, user):
204   - logger.debug(f'finished_topic({user})')
205   -
206   - # state = self.learn.get_student_state(user) # all topics
207   - # current_topic = self.learn.get_student_topic(uid)
208   -
  198 + def finished_topic(self, user): # FIXME user unused
209 199 return {
210 200 'method': 'finished_topic',
211 201 'params': {
212   - 'question': '<img src="/static/trophy.png" alt="trophy" class="img-fluid mx-auto d-block" width="30%">',
213   - 'progress': 1.0,
  202 + 'question': f'<img src="/static/trophy.png" alt="trophy" class="img-fluid mx-auto d-block" width="30%">',
  203 + # 'progress': 1.0,
214 204 }
215 205 }
216 206  
... ... @@ -221,7 +211,7 @@ class QuestionHandler(BaseHandler):
221 211  
222 212 question = self.learn.get_student_question(user)
223 213 template = self.templates[question['type']]
224   - question_html = self.render_string(template, question=question, md=md)
  214 + question_html = self.render_string(template, question=question, md=md_to_html)
225 215  
226 216 self.write({
227 217 'method': 'new_question',
... ... @@ -237,12 +227,9 @@ class QuestionHandler(BaseHandler):
237 227 logging.debug('QuestionHandler.post()')
238 228 user = self.current_user
239 229  
240   - print(self.get_body_arguments())
241   - # if self.learn.get_student_question(user) is None:
242   -
243   - answer = self.get_body_argument('answer')
244   -
245 230 # check answer and get next question (same, new or None)
  231 + answer = self.get_body_arguments('answer') # list
  232 + print(answer)
246 233 grade = self.learn.check_answer(user, answer)
247 234 question = self.learn.get_student_question(user)
248 235  
... ...
templates/maintopics.html
... ... @@ -35,17 +35,26 @@
35 35 <span class="navbar-toggler-icon"></span>
36 36 </button>
37 37  
38   - <div class="collapse navbar-collapse" id="navbarText">
39   - <ul class="navbar-nav mr-auto">
40   - </ul>
41   -
42   - <span class="navbar-text">
43   - <i class="fa fa-user" aria-hidden="true"></i>
44   - <span id="name">{{ escape(name) }}</span>
45   - (<span id="number">{{ escape(uid) }}</span>)
46   - <span class="caret"></span>
47   - </span>
48   - </div>
  38 +
  39 + <div class="collapse navbar-collapse" id="navbarText">
  40 + <ul class="navbar-nav mr-auto"></ul>
  41 +
  42 + <ul class="navbar-nav">
  43 + <li class="nav-item dropdown">
  44 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
  45 + <i class="fa fa-user" aria-hidden="true"></i>
  46 + <span id="name">{{ escape(name) }}</span>
  47 + (<span id="number">{{ escape(uid) }}</span>)
  48 + <span class="caret"></span>
  49 + </a>
  50 + <div class="dropdown-menu" aria-labelledby="navbarDropdown">
  51 + <a class="dropdown-item" data-toggle="modal" data-target="#password_modal">Mudar Password</a>
  52 + <div class="dropdown-divider"></div>
  53 + <a class="dropdown-item" href="/logout">Sair</a>
  54 + </div>
  55 + </li>
  56 + </ul>
  57 + </div>
49 58 </nav>
50 59 <!-- ===================================================================== -->
51 60 <div class="container">
... ... @@ -63,7 +72,7 @@
63 72 {{ t['name'] }}
64 73 </div>
65 74 <div class="ml-auto p-2">
66   - <i class="fa fa-lock" aria-hidden="true"></i>
  75 + <i class="fa fa-lock text-danger" aria-hidden="true"></i>
67 76 </div>
68 77 </div>
69 78 </a>
... ... @@ -75,9 +84,10 @@
75 84 </div>
76 85 <div class="ml-auto p-2">
77 86 {% if t['level'] < 0.01 %}
78   - <i class="fa fa-unlock float-right" aria-hidden="true"></i>
  87 +
  88 + <i class="fa fa-unlock text-success" aria-hidden="true"></i>
79 89 {% else %}
80   - {{ round(t['level']*5)*'<i class="fa fa-star text-success" aria-hidden="true"></i>' + round(5-t['level']*5)*'<i class="fa fa-star-o" aria-hidden="true"></i>' }}
  90 + {{ round(t['level']*5)*'<i class="fa fa-star text-warning" aria-hidden="true"></i>' + round(5-t['level']*5)*'<i class="fa fa-star-o" aria-hidden="true"></i>' }}
81 91 {% end %}
82 92 </div>
83 93 </div>
... ... @@ -93,8 +103,8 @@
93 103 <div class="modal-content">
94 104 <!-- header -->
95 105 <div class="modal-header">
96   - <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button>
97   - <h4 class="modal-title">Alterar Password</h4>
  106 + <!-- <button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">&times;</span></button> -->
  107 + <h5 class="modal-title">Alterar Password</h5>
98 108 </div>
99 109 <!-- body -->
100 110 <div class="modal-body">
... ...
templates/topic.html
... ... @@ -36,6 +36,20 @@
36 36 body {
37 37 margin: 0;
38 38 padding-top: 55px;
  39 + margin-bottom: 60px; /* Margin bottom by footer height */
  40 + }
  41 + .footer {
  42 + position: absolute;
  43 + bottom: 0;
  44 + width: 100%;
  45 + /* Set the fixed height of the footer here */
  46 + height: 60px;
  47 + line-height: 60px; /* Vertically center the text there */
  48 + background-color: #f5f5f5;
  49 + }
  50 + html {
  51 + position: relative;
  52 + min-height: 100%;
39 53 }
40 54 </style>
41 55  
... ... @@ -45,12 +59,12 @@
45 59  
46 60 <!-- Navbar -->
47 61 <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-dark">
48   - <a class="navbar-brand" href="#">
  62 + <a class="navbar-brand" href="#">
49 63 <img src="/static/logo_horizontal.png" height="30" alt="">
50   - </a>
51   - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  64 + </a>
  65 + <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
52 66 <span class="navbar-toggler-icon"></span>
53   - </button>
  67 + </button>
54 68  
55 69 <div class="collapse navbar-collapse" id="navbarText">
56 70 <ul class="navbar-nav mr-auto"></ul>
... ... @@ -65,7 +79,7 @@
65 79 </a>
66 80 <div class="dropdown-menu" aria-labelledby="navbarDropdown">
67 81 <!-- <div class="dropdown-divider"></div> -->
68   - <a class="dropdown-item" href="#">Sair</a>
  82 + <a class="dropdown-item" href="/logout">Sair</a>
69 83 </div>
70 84 </li>
71 85 </ul>
... ... @@ -82,17 +96,24 @@
82 96  
83 97 <div id="notifications"></div>
84 98  
85   - <div id="content">
  99 + <div class="my-5" id="content">
86 100 <form action="/question" method="post" id="question_form" autocomplete="off">
87 101 {% module xsrf_form_html() %}
88   -
89 102 <div id="question_div"></div>
90   -
91 103 </form>
92   - <button class="btn btn-primary" id="submit" data-toggle="tooltip" data-placement="right" title="Shift-Enter">Continuar</button>
  104 +
  105 + <button class="btn btn-primary btn-lg btn-block my-3" id="submit" data-toggle="tooltip" data-placement="right" title="Shift-Enter">Continuar</button>
93 106 </div>
94 107 </div>
95 108  
  109 +<footer class="footer">
  110 + <div class="container">
  111 + <span class="text-muted"></span>
  112 + </div>
  113 +</footer>
  114 +
  115 +
  116 +
96 117 <!-- ===================================================================== -->
97 118 <!-- JAVASCRIPT -->
98 119 <!-- ===================================================================== -->
... ... @@ -154,8 +175,9 @@
154 175 break;
155 176  
156 177 case "finished_topic":
  178 + $('#submit').css("visibility", "hidden");
  179 + $("#content").html(response["params"]["question"]);
157 180 $('#topic_progress').css('width', '100%').attr('aria-valuenow', 100);
158   - $("#content").html('<img src="/static/trophy.png" alt="trophy" class="img-fluid mx-auto my-5 d-block" width="25%">');
159 181 $("#content").animateCSS('tada');
160 182 setTimeout(function(){ window.location.replace('/'); }, 2000);
161 183 break;
... ...
tools.py
1 1  
  2 +# builtin
  3 +from os import path
2 4 import subprocess
3 5 import logging
  6 +import re
  7 +
  8 +# packages
4 9 import yaml
5   -import markdown
  10 +import mistune
  11 +from pygments import highlight
  12 +from pygments.lexers import get_lexer_by_name
  13 +from pygments.formatters import HtmlFormatter
6 14  
7 15 # setup logger for this module
8 16 logger = logging.getLogger(__name__)
9 17  
  18 +
  19 +# ---------------------------------------------------------------------------
  20 +# Markdown to HTML renderer with support for LaTeX equations
  21 +# Inline math: $x$
  22 +# Block math: $$x$$ or \begin{equation}x\end{equation}
  23 +# ---------------------------------------------------------------------------
  24 +class MathBlockGrammar(mistune.BlockGrammar):
  25 + block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL)
  26 + latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL)
  27 +
  28 +
  29 +class MathBlockLexer(mistune.BlockLexer):
  30 + default_rules = ['block_math', 'latex_environment'] + mistune.BlockLexer.default_rules
  31 +
  32 + def __init__(self, rules=None, **kwargs):
  33 + if rules is None:
  34 + rules = MathBlockGrammar()
  35 + super().__init__(rules, **kwargs)
  36 +
  37 + def parse_block_math(self, m):
  38 + """Parse a $$math$$ block"""
  39 + self.tokens.append({
  40 + 'type': 'block_math',
  41 + 'text': m.group(1)
  42 + })
  43 +
  44 + def parse_latex_environment(self, m):
  45 + self.tokens.append({
  46 + 'type': 'latex_environment',
  47 + 'name': m.group(1),
  48 + 'text': m.group(2)
  49 + })
  50 +
  51 +
  52 +class MathInlineGrammar(mistune.InlineGrammar):
  53 + math = re.compile(r"^\$(.+?)\$", re.DOTALL)
  54 + block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL)
  55 + text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)')
  56 +
  57 +
  58 +class MathInlineLexer(mistune.InlineLexer):
  59 + default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules
  60 +
  61 + def __init__(self, renderer, rules=None, **kwargs):
  62 + if rules is None:
  63 + rules = MathInlineGrammar()
  64 + super().__init__(renderer, rules, **kwargs)
  65 +
  66 + def output_math(self, m):
  67 + return self.renderer.inline_math(m.group(1))
  68 +
  69 + def output_block_math(self, m):
  70 + return self.renderer.block_math(m.group(1))
  71 +
  72 +
  73 +class MarkdownWithMath(mistune.Markdown):
  74 + def __init__(self, renderer, **kwargs):
  75 + if 'inline' not in kwargs:
  76 + kwargs['inline'] = MathInlineLexer
  77 + if 'block' not in kwargs:
  78 + kwargs['block'] = MathBlockLexer
  79 + super().__init__(renderer, **kwargs)
  80 +
  81 + def output_block_math(self):
  82 + return self.renderer.block_math(self.token['text'])
  83 +
  84 + def output_latex_environment(self):
  85 + return self.renderer.latex_environment(self.token['name'], self.token['text'])
  86 +
  87 +
  88 +
  89 +class HighlightRenderer(mistune.Renderer):
  90 + def block_code(self, code, lang='text'):
  91 + try:
  92 + lexer = get_lexer_by_name(lang, stripall=True)
  93 + except:
  94 + lexer = get_lexer_by_name('text', stripall=True)
  95 +
  96 + formatter = HtmlFormatter()
  97 + # FIXME indentation is striped from first line of code, seems bug in mistune
  98 + return highlight(code, lexer, formatter)
  99 +
  100 + # def table(self, header, body):
  101 + # return "<table class='table table-bordered table-hover'>" + header + body + "</table>"
  102 +
  103 + # def image(self, src, title, text):
  104 + # if src.startswith('javascript:'):
  105 + # src = ''
  106 + # text = mistune.escape(text, quote=True)
  107 + # if title:
  108 + # title = mistune.escape(title, quote=True)
  109 + # html = '<img class="img-responsive center-block" src="%s" alt="%s" title="%s"' % (src, text, title)
  110 + # else:
  111 + # html = '<img class="img-responsive center-block" src="%s" alt="%s"' % (src, text)
  112 + # if self.options.get('use_xhtml'):
  113 + # return '%s />' % html
  114 + # return '%s>' % html
  115 +
  116 +
  117 + # Pass math through unaltered - mathjax does the rendering in the browser
  118 + def block_math(self, text):
  119 + return fr'$$ {text} $$'
  120 +
  121 + def latex_environment(self, name, text):
  122 + return fr'\begin{{{name}}} {text} \end{{{name}}}'
  123 +
  124 + def inline_math(self, text):
  125 + return fr'$$$ {text} $$$'
  126 +
  127 +
  128 +markdown = MarkdownWithMath(HighlightRenderer(escape=False)) # hard_wrap=True to insert <br> on newline
  129 +
  130 +def md_to_html(text, q=None):
  131 + return markdown(text)
  132 +
10 133 # ---------------------------------------------------------------------------
11 134 # load data from yaml file
12 135 # ---------------------------------------------------------------------------
13 136 def load_yaml(filename, default=None):
14 137 try:
15   - f = open(filename, 'r', encoding='utf-8')
  138 + f = open(path.expanduser(filename), 'r', encoding='utf-8')
16 139 except IOError:
17 140 logger.error(f'Can\'t open file "{filename}"')
18 141 return default
... ... @@ -22,14 +145,14 @@ def load_yaml(filename, default=None):
22 145 return yaml.load(f)
23 146 except yaml.YAMLError as e:
24 147 mark = e.problem_mark
25   - logger.error('In YAML file "{0}" near line {1}, column {2}.'.format(filename, mark.line, mark.column+1))
  148 + logger.error(f'In YAML file "{filename}" near line {mark.line}, column {mark.column+1}.')
26 149 return default
27 150  
28 151 # ---------------------------------------------------------------------------
29 152 # Runs a script and returns its stdout parsed as yaml, or None on error.
30   -# Note: requires python 3.5+
31 153 # ---------------------------------------------------------------------------
32 154 def run_script(script, stdin='', timeout=5):
  155 + script = path.expanduser(script)
33 156 try:
34 157 p = subprocess.run([script],
35 158 input=stdin,
... ... @@ -39,76 +162,20 @@ def run_script(script, stdin=&#39;&#39;, timeout=5):
39 162 timeout=timeout,
40 163 )
41 164 except FileNotFoundError:
42   - logger.error('Script not found: "{0}".'.format(script))
  165 + logger.error(f'Could not execute script "{script}": not found.')
43 166 except PermissionError:
44   - logger.error('Script "{0}" not executable (wrong permissions?).'.format(script))
  167 + logger.error(f'Could not execute script "{script}": wrong permissions.')
  168 + except OSError:
  169 + logger.error(f'Could not execute script "{script}": unknown reason.')
45 170 except subprocess.TimeoutExpired:
46   - logger.error('Timeout {0}s exceeded while running script "{1}"'.format(timeout, script))
  171 + logger.error(f'Timeout exceeded ({timeout}s) while running "{script}".')
47 172 else:
48 173 if p.returncode != 0:
49   - logger.error('Script "{0}" returned error code {1}.'.format(script, p.returncode))
  174 + logger.error(f'Script "{script}" returned error code {p.returncode}.')
50 175 else:
51 176 try:
52 177 output = yaml.load(p.stdout)
53 178 except:
54   - logger.error('Error parsing yaml output of script "{0}"'.format(script))
  179 + logger.error(f'Error parsing yaml output of "{script}"')
55 180 else:
56 181 return output
57   -
58   -
59   -
60   -# markdown helper
61   -# returns a function md() that renders markdown with extensions
62   -# this function is passed to templates for rendering
63   -def md(text):
64   - return markdown.markdown(text,
65   - extensions=[
66   - 'markdown.extensions.tables',
67   - 'markdown.extensions.fenced_code',
68   - 'markdown.extensions.codehilite',
69   - 'markdown.extensions.def_list',
70   - 'markdown.extensions.sane_lists'
71   - ])
72   -
73   -
74   -
75   -
76   -
77   -
78   -
79   -
80   -
81   -
82   -
83   -
84   -
85   -
86   -
87   -
88   -
89   -
90   -
91   -# def md_to_html(text, ref=None, files={}):
92   -# if ref is not None:
93   -# # given q['ref'] and q['files'] replaces references to files by a
94   -# # GET to /file?ref=???;name=???
95   -# for k in files:
96   -# text = text.replace(k, '/file?ref={};name={}'.format(ref, k))
97   -# return markdown.markdown(text, extensions=[
98   -# 'markdown.extensions.tables',
99   -# 'markdown.extensions.fenced_code',
100   -# 'markdown.extensions.codehilite',
101   -# 'markdown.extensions.def_list',
102   -# 'markdown.extensions.sane_lists'
103   -# ])
104   -
105   -# def md_to_html_review(text, q):
106   -# for k,f in q['files'].items():
107   -# text = text.replace(k, '/absfile?name={}'.format(q['files'][k]))
108   -# return markdown.markdown(text, extensions=[
109   -# 'markdown.extensions.tables',
110   -# 'markdown.extensions.fenced_code',
111   -# 'markdown.extensions.codehilite',
112   -# 'markdown.extensions.def_list',
113   -# 'markdown.extensions.sane_lists'
114   -# ])
... ...