Commit a6b50da08def113bd299f00b1f8933ab59062153

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

fixes error where progress is not being saved in the database

BUGS.md
1 1  
2 2 # BUGS
3 3  
  4 +- ir para inicio da pagina quando le nova pergunta.
4 5 - nao esta a seguir o max_tries definido no ficheiro de dependencias.
5 6 - devia mostrar timeout para o aluno saber a razao.
6 7 - permitir configuracao para escolher entre static files locais ou remotos
... ... @@ -31,6 +32,8 @@
31 32  
32 33 # FIXED
33 34  
  35 +- CRITICAL nao esta a guardar o progresso na base de dados.
  36 +- mesma ref no mesmo ficheiro não é detectado.
34 37 - enter nas respostas mostra json
35 38 - apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
36 39 - double click submits twice.
... ...
aprendizations/learnapp.py
... ... @@ -7,7 +7,7 @@ from datetime import datetime
7 7 import logging
8 8 from random import random
9 9 from os import path
10   -from typing import Any, Dict, Iterable, List, Optional, Tuple
  10 +from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
11 11  
12 12 # third party libraries
13 13 import bcrypt
... ... @@ -88,9 +88,9 @@ class LearnApp(object):
88 88 self.courses = config['courses']
89 89 logger.info(f'Courses: {", ".join(self.courses.keys())}')
90 90 for c, d in self.courses.items():
  91 + d.setdefault('title', '') # course title undefined
91 92 for goal in d['goals']:
92 93 if goal not in self.deps.nodes():
93   - # logger.error(f'Goal "{goal}" of "{c}"" not in the graph')
94 94 raise LearnException(f'Goal "{goal}" from course "{c}" '
95 95 ' does not exist')
96 96  
... ... @@ -229,9 +229,8 @@ class LearnApp(object):
229 229 # ------------------------------------------------------------------------
230 230 async def check_answer(self, uid: str, answer) -> Question:
231 231 student = self.online[uid]['state']
232   - topic = student.get_current_topic()
233   -
234   - q = await student.check_answer(answer)
  232 + await student.check_answer(answer)
  233 + q = student.get_current_question()
235 234  
236 235 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
237 236  
... ... @@ -243,14 +242,24 @@ class LearnApp(object):
243 242 starttime=str(q['start_time']),
244 243 finishtime=str(q['finish_time']),
245 244 student_id=uid,
246   - topic_id=topic))
247   - logger.debug(f'db insert answer of {q["ref"]}')
  245 + topic_id=student.get_current_topic()))
  246 +
  247 + return q
  248 +
  249 + # ------------------------------------------------------------------------
  250 + # get the question to show (current or new one)
  251 + # if no more questions, save/update level in database
  252 + # ------------------------------------------------------------------------
  253 + async def get_question(self, uid: str) -> Optional[Question]:
  254 + student = self.online[uid]['state']
  255 + q: Optional[Question] = await student.get_question()
248 256  
249   - # save topic if finished
  257 + # save topic to database if finished
250 258 if student.topic_has_finished():
251   - logger.info(f'User "{uid}" finished "{topic}"')
  259 + topic: str = student.get_previous_topic()
252 260 level: float = student.get_topic_level(topic)
253 261 date: str = str(student.get_topic_date(topic))
  262 + logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})')
254 263  
255 264 with self.db_session() as s:
256 265 a = s.query(StudentTopic) \
... ... @@ -276,12 +285,6 @@ class LearnApp(object):
276 285 return q
277 286  
278 287 # ------------------------------------------------------------------------
279   - # get the question to show (current or new one)
280   - # ------------------------------------------------------------------------
281   - async def get_question(self, uid: str) -> Optional[Question]:
282   - return await self.online[uid]['state'].get_question()
283   -
284   - # ------------------------------------------------------------------------
285 288 # Start course
286 289 # ------------------------------------------------------------------------
287 290 def start_course(self, uid: str, course: str) -> None:
... ... @@ -409,14 +412,28 @@ class LearnApp(object):
409 412 fullpath: str = path.join(topicpath, t['file'])
410 413  
411 414 logger.debug(f' Loading {fullpath}')
412   - questions: List[QDict] = load_yaml(fullpath, default=[])
  415 + # questions: List[QDict] = load_yaml(fullpath, default=[])
  416 + try:
  417 + questions: List[QDict] = load_yaml(fullpath)
  418 + except Exception:
  419 + raise LearnException(f'Failed to load "{fullpath}"')
  420 +
  421 + if not isinstance(questions, list):
  422 + msg = f'File "{fullpath}" must be a list of questions'
  423 + raise LearnException(msg)
413 424  
414 425 # update refs to include topic as prefix.
415 426 # refs are required to be unique only within the file.
416 427 # undefined are set to topic:n, where n is the question number
417 428 # within the file
  429 + localrefs: Set[str] = set() # refs in current file
418 430 for i, q in enumerate(questions):
419 431 qref = q.get('ref', str(i)) # ref or number
  432 + if qref in localrefs:
  433 + msg = f'Duplicate ref "{qref}" in "{topicpath}"'
  434 + raise LearnException(msg)
  435 + localrefs.add(qref)
  436 +
420 437 q['ref'] = f'{tref}:{qref}'
421 438 q['path'] = topicpath
422 439 q.setdefault('append_wrong', t['append_wrong'])
... ... @@ -439,7 +456,7 @@ class LearnApp(object):
439 456  
440 457 # ------------------------------------------------------------------------
441 458 def get_login_counter(self, uid: str) -> int:
442   - return self.online[uid]['counter']
  459 + return int(self.online[uid]['counter'])
443 460  
444 461 # ------------------------------------------------------------------------
445 462 def get_student_name(self, uid: str) -> str:
... ... @@ -474,7 +491,7 @@ class LearnApp(object):
474 491 return self.online[uid]['state'].get_current_course_title()
475 492  
476 493 # ------------------------------------------------------------------------
477   - def get_student_course_id(self, uid: str) -> str:
  494 + def get_student_course_id(self, uid: str) -> Optional[str]:
478 495 return self.online[uid]['state'].get_current_course_id()
479 496  
480 497 # ------------------------------------------------------------------------
... ... @@ -520,7 +537,7 @@ class LearnApp(object):
520 537 # compute topic progress
521 538 now = datetime.now()
522 539 goals = self.courses[course_id]['goals']
523   - prog = defaultdict(int)
  540 + prog: DefaultDict[str, float] = defaultdict(int)
524 541  
525 542 for uid, topic, level, date in student_topics:
526 543 if topic in goals:
... ...
aprendizations/questions.py
1 1  
2 2 # python standard library
3 3 import asyncio
  4 +from datetime import datetime
4 5 import random
5 6 import re
6 7 from os import path
... ... @@ -44,6 +45,10 @@ class Question(dict):
44 45 'files': {},
45 46 }))
46 47  
  48 + def set_answer(self, ans) -> None:
  49 + self['answer'] = ans
  50 + self['finish_time'] = datetime.now()
  51 +
47 52 def correct(self) -> None:
48 53 self['comments'] = ''
49 54 self['grade'] = 0.0
... ... @@ -74,7 +79,14 @@ class QuestionRadio(Question):
74 79 def __init__(self, q: QDict) -> None:
75 80 super().__init__(q)
76 81  
77   - n = len(self['options'])
  82 + try:
  83 + n = len(self['options'])
  84 + except KeyError:
  85 + msg = f'Missing `options` in radio question. See {self["path"]}'
  86 + raise QuestionException(msg)
  87 + except TypeError:
  88 + msg = f'`options` must be a list. See {self["path"]}'
  89 + raise QuestionException(msg)
78 90  
79 91 self.set_defaults(QDict({
80 92 'text': '',
... ... @@ -165,7 +177,7 @@ class QuestionRadio(Question):
165 177 # x_aver can be exactly 1.0 if all options are right
166 178 if self['discount'] and x_aver != 1.0:
167 179 x = (x - x_aver) / (1.0 - x_aver)
168   - self['grade'] = x
  180 + self['grade'] = float(x)
169 181  
170 182  
171 183 # ============================================================================
... ... @@ -185,7 +197,14 @@ class QuestionCheckbox(Question):
185 197 def __init__(self, q: QDict) -> None:
186 198 super().__init__(q)
187 199  
188   - n = len(self['options'])
  200 + try:
  201 + n = len(self['options'])
  202 + except KeyError:
  203 + msg = f'Missing `options` in radio question. See {self["path"]}'
  204 + raise QuestionException(msg)
  205 + except TypeError:
  206 + msg = f'`options` must be a list. See {self["path"]}'
  207 + raise QuestionException(msg)
189 208  
190 209 # set defaults if missing
191 210 self.set_defaults(QDict({
... ...
aprendizations/serve.py
... ... @@ -293,13 +293,18 @@ class QuestionHandler(BaseHandler):
293 293 'alert': 'question-information.html',
294 294 }
295 295  
296   - # --- get question to render
  296 + # ------------------------------------------------------------------------
  297 + # GET
  298 + # gets question to render. If there are no more questions in the topic
  299 + # shows an animated trophy
  300 + # ------------------------------------------------------------------------
297 301 @tornado.web.authenticated
298 302 async def get(self):
299 303 logger.debug('[QuestionHandler]')
300 304 user = self.current_user
301 305 q = await self.learn.get_question(user)
302 306  
  307 + # show current question
303 308 if q is not None:
304 309 qhtml = self.render_string(self.templates[q['type']],
305 310 question=q, md=md_to_html)
... ... @@ -313,6 +318,7 @@ class QuestionHandler(BaseHandler):
313 318 }
314 319 }
315 320  
  321 + # show animated trophy
316 322 else:
317 323 finished = self.render_string('finished_topic.html')
318 324 response = {
... ... @@ -324,7 +330,11 @@ class QuestionHandler(BaseHandler):
324 330  
325 331 self.write(response)
326 332  
327   - # --- post answer, returns what to do next: shake, new_question, finished
  333 + # ------------------------------------------------------------------------
  334 + # POST
  335 + # corrects answer and returns status: right, wrong, try_again
  336 + # does not move to next question.
  337 + # ------------------------------------------------------------------------
328 338 @tornado.web.authenticated
329 339 async def post(self) -> None:
330 340 user = self.current_user
... ... @@ -364,6 +374,7 @@ class QuestionHandler(BaseHandler):
364 374  
365 375 # --- built response to return
366 376 response = {'method': q['status'], 'params': {}}
  377 +
367 378 if q['status'] == 'right': # get next question in the topic
368 379 comments_html = self.render_string(
369 380 'comments-right.html', comments=q['comments'], md=md_to_html)
... ...
aprendizations/student.py
... ... @@ -17,12 +17,21 @@ logger = logging.getLogger(__name__)
17 17  
18 18  
19 19 # ----------------------------------------------------------------------------
20   -# kowledge state of a student
21   -# Contains:
  20 +# kowledge state of a student:
  21 +# uid - string with userid, e.g. '12345'
22 22 # state - dict of unlocked topics and their levels
23   -# deps - access to dependency graph shared between students
24   -# topic_sequence - list with the recommended topic sequence
25   -# current_topic - nameref of the current topic
  23 +# {'topic1': {'level': 0.5, 'date': datetime}, ...}
  24 +# topic_sequence - recommended topic sequence ['topic1', 'topic2', ...]
  25 +# questions - [Question, ...] for the current topic
  26 +# current_course - string or None
  27 +# current_topic - string or None
  28 +# current_question - Question or None
  29 +#
  30 +#
  31 +# also has access to shared data between students:
  32 +# courses - dictionary {course: [topic1, ...]}
  33 +# deps - dependency graph as a networkx digraph
  34 +# factory - dictionary {ref: QFactory}
26 35 # ----------------------------------------------------------------------------
27 36 class StudentState(object):
28 37 # =======================================================================
... ... @@ -50,6 +59,7 @@ class StudentState(object):
50 59 self.current_course: Optional[str] = None
51 60 self.topic_sequence: List[str] = []
52 61 self.current_topic: Optional[str] = None
  62 + # self.previous_topic: Optional[str] = None
53 63 else:
54 64 logger.debug(f'starting course {course}')
55 65 self.current_course = course
... ... @@ -58,7 +68,7 @@ class StudentState(object):
58 68  
59 69 # ------------------------------------------------------------------------
60 70 # Start a new topic.
61   - # questions: list of generated questions to do in the topic
  71 + # questions: list of generated questions to do in the given topic
62 72 # current_question: the current question to be presented
63 73 # ------------------------------------------------------------------------
64 74 async def start_topic(self, topic: str) -> None:
... ... @@ -74,9 +84,10 @@ class StudentState(object):
74 84 logger.debug(f'is locked "{topic}"')
75 85 return
76 86  
  87 + self.previous_topic: Optional[str] = None
  88 +
77 89 # choose k questions
78 90 self.current_topic = topic
79   - # self.current_question = None
80 91 self.correct_answers = 0
81 92 self.wrong_answers = 0
82 93 t = self.deps.nodes[topic]
... ... @@ -96,30 +107,17 @@ class StudentState(object):
96 107 self.next_question()
97 108  
98 109 # ------------------------------------------------------------------------
99   - # The topic has finished and there are no more questions.
100   - # The topic level is updated in state and unlocks are performed.
101   - # The current topic is unchanged.
102   - # ------------------------------------------------------------------------
103   - def finish_topic(self) -> None:
104   - logger.debug(f'finished {self.current_topic}')
105   -
106   - self.state[self.current_topic] = {
107   - 'date': datetime.now(),
108   - 'level': self.correct_answers / (self.correct_answers +
109   - self.wrong_answers)
110   - }
111   - self.current_topic = None
112   - self.current_question = None
113   - self.unlock_topics()
114   -
115   - # ------------------------------------------------------------------------
116 110 # corrects current question
  111 + # updates keys: answer, grade, finish_time, status, tries
117 112 # ------------------------------------------------------------------------
118   - async def check_answer(self, answer) -> Tuple[Question, str]:
  113 + async def check_answer(self, answer) -> None:
119 114 q = self.current_question
120   - q['answer'] = answer
121   - q['finish_time'] = datetime.now()
122   - await q.correct_async()
  115 + if q is None:
  116 + logger.error('check_answer called but current_question is None!')
  117 + return None
  118 +
  119 + q.set_answer(answer)
  120 + await q.correct_async() # updates q['grade']
123 121  
124 122 if q['grade'] > 0.999:
125 123 self.correct_answers += 1
... ... @@ -134,13 +132,17 @@ class StudentState(object):
134 132 q['status'] = 'wrong'
135 133  
136 134 logger.debug(f'ref = {q["ref"]}, status = {q["status"]}')
137   - return q
138 135  
139 136 # ------------------------------------------------------------------------
140   - # get question to show, current or next
  137 + # gets next question to show if the status is 'right' or 'wrong',
  138 + # otherwise just returns the current question
141 139 # ------------------------------------------------------------------------
142 140 async def get_question(self) -> Optional[Question]:
143 141 q = self.current_question
  142 + if q is None:
  143 + logger.error('get_question called but current_question is None!')
  144 + return None
  145 +
144 146 logger.debug(f'{q["ref"]} status = {q["status"]}')
145 147  
146 148 if q['status'] == 'right':
... ... @@ -151,10 +153,6 @@ class StudentState(object):
151 153 new_question = await self.factory[q['ref']].gen_async()
152 154 self.questions.append(new_question)
153 155 self.next_question()
154   - # elif q['status'] == 'new':
155   - # pass
156   - # elif q['status'] == 'try_again':
157   - # pass
158 156  
159 157 return self.current_question
160 158  
... ... @@ -172,7 +170,25 @@ class StudentState(object):
172 170 q['start_time'] = datetime.now()
173 171 q['tries'] = q.get('max_tries', t['max_tries'])
174 172 q['status'] = 'new'
175   - self.current_question = q
  173 + self.current_question: Optional[Question] = q
  174 +
  175 + # ------------------------------------------------------------------------
  176 + # The topic has finished and there are no more questions.
  177 + # The topic level is updated in state and unlocks are performed.
  178 + # The current topic is unchanged.
  179 + # ------------------------------------------------------------------------
  180 + def finish_topic(self) -> None:
  181 + logger.debug(f'finished "{self.current_topic}"')
  182 +
  183 + self.state[self.current_topic] = {
  184 + 'date': datetime.now(),
  185 + 'level': self.correct_answers / (self.correct_answers +
  186 + self.wrong_answers)
  187 + }
  188 + self.previous_topic = self.current_topic
  189 + self.current_topic = None
  190 + self.current_question = None
  191 + self.unlock_topics()
176 192  
177 193 # ------------------------------------------------------------------------
178 194 # Update proficiency level of the topics using a forgetting factor
... ... @@ -211,7 +227,7 @@ class StudentState(object):
211 227 # ========================================================================
212 228  
213 229 def topic_has_finished(self) -> bool:
214   - return self.current_topic is None
  230 + return self.current_topic is None and self.previous_topic is not None
215 231  
216 232 # ------------------------------------------------------------------------
217 233 # compute recommended sequence of topics ['a', 'b', ...]
... ... @@ -238,8 +254,12 @@ class StudentState(object):
238 254 return self.current_topic
239 255  
240 256 # ------------------------------------------------------------------------
241   - def get_current_course_title(self) -> Optional[str]:
242   - return self.courses[self.current_course]['title']
  257 + def get_previous_topic(self) -> Optional[str]:
  258 + return self.previous_topic
  259 +
  260 + # ------------------------------------------------------------------------
  261 + def get_current_course_title(self) -> str:
  262 + return str(self.courses[self.current_course]['title'])
243 263  
244 264 # ------------------------------------------------------------------------
245 265 def get_current_course_id(self) -> Optional[str]:
... ... @@ -269,7 +289,7 @@ class StudentState(object):
269 289  
270 290 # ------------------------------------------------------------------------
271 291 def get_topic_level(self, topic: str) -> float:
272   - return self.state[topic]['level']
  292 + return float(self.state[topic]['level'])
273 293  
274 294 # ------------------------------------------------------------------------
275 295 def get_topic_date(self, topic: str):
... ...
aprendizations/tools.py
... ... @@ -111,14 +111,13 @@ class HighlightRenderer(mistune.Renderer):
111 111  
112 112 def table(self, header, body):
113 113 return ('<table class="table table-sm"><thead class="thead-light">'
114   - f'{header}</thead><tbody>{body}</tbody></table>')
  114 + f'{header}</thead><tbody>{body}</tbody></table>')
115 115  
116 116 def image(self, src, title, alt):
117 117 alt = mistune.escape(alt, quote=True)
118 118 title = mistune.escape(title or '', quote=True)
119 119 return (f'<img src="/file/{src}" alt="{alt}" title="{title}"'
120   - 'class="img-fluid">')
121   - # class="img-fluid mx-auto d-block"
  120 + f'class="img-fluid">') # class="img-fluid mx-auto d-block"
122 121  
123 122 # Pass math through unaltered - mathjax does the rendering in the browser
124 123 def block_math(self, text):
... ... @@ -150,25 +149,22 @@ def load_yaml(filename: str, default: Any = None) -&gt; Any:
150 149 filename = path.expanduser(filename)
151 150 try:
152 151 f = open(filename, 'r', encoding='utf-8')
153   - except FileNotFoundError:
154   - logger.error(f'Cannot open "{filename}": not found')
155   - except PermissionError:
156   - logger.error(f'Cannot open "{filename}": no permission')
157   - except OSError:
158   - logger.error(f'Cannot open file "{filename}"')
159   - else:
160   - with f:
161   - try:
162   - default = yaml.safe_load(f)
163   - except yaml.YAMLError as e:
164   - if hasattr(e, 'problem_mark'):
165   - mark = e.problem_mark
166   - logger.error(f'File "{filename}" near line {mark.line+1}, '
167   - f'column {mark.column+1}')
168   - else:
169   - logger.error(f'File "{filename}"')
170   - finally:
171   - return default
  152 + except Exception as e:
  153 + logger.error(e)
  154 + if default is not None:
  155 + return default
  156 + else:
  157 + raise
  158 +
  159 + with f:
  160 + try:
  161 + return yaml.safe_load(f)
  162 + except yaml.YAMLError as e:
  163 + logger.error(str(e).replace('\n', ' '))
  164 + if default is not None:
  165 + return default
  166 + else:
  167 + raise
172 168  
173 169  
174 170 # ---------------------------------------------------------------------------
... ...