Commit 7cf84cc655a0814f8d9a5f37c2495516e4c9d6d9

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

- fix critical error where progress was not being saved in the database

- add some type annotations
@@ -31,6 +31,7 @@ @@ -31,6 +31,7 @@
31 31
32 # FIXED 32 # FIXED
33 33
  34 +- CRITICAL nao esta a guardar o progresso na base de dados.
34 - mesma ref no mesmo ficheiro não é detectado. 35 - mesma ref no mesmo ficheiro não é detectado.
35 - enter nas respostas mostra json 36 - enter nas respostas mostra json
36 - apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande) 37 - apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
aprendizations/learnapp.py
@@ -7,7 +7,7 @@ from datetime import datetime @@ -7,7 +7,7 @@ from datetime import datetime
7 import logging 7 import logging
8 from random import random 8 from random import random
9 from os import path 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 # third party libraries 12 # third party libraries
13 import bcrypt 13 import bcrypt
@@ -88,6 +88,7 @@ class LearnApp(object): @@ -88,6 +88,7 @@ class LearnApp(object):
88 self.courses = config['courses'] 88 self.courses = config['courses']
89 logger.info(f'Courses: {", ".join(self.courses.keys())}') 89 logger.info(f'Courses: {", ".join(self.courses.keys())}')
90 for c, d in self.courses.items(): 90 for c, d in self.courses.items():
  91 + d.setdefault('title', '') # course title undefined
91 for goal in d['goals']: 92 for goal in d['goals']:
92 if goal not in self.deps.nodes(): 93 if goal not in self.deps.nodes():
93 raise LearnException(f'Goal "{goal}" from course "{c}" ' 94 raise LearnException(f'Goal "{goal}" from course "{c}" '
@@ -228,9 +229,8 @@ class LearnApp(object): @@ -228,9 +229,8 @@ class LearnApp(object):
228 # ------------------------------------------------------------------------ 229 # ------------------------------------------------------------------------
229 async def check_answer(self, uid: str, answer) -> Question: 230 async def check_answer(self, uid: str, answer) -> Question:
230 student = self.online[uid]['state'] 231 student = self.online[uid]['state']
231 - topic = student.get_current_topic()  
232 -  
233 - q = await student.check_answer(answer) 232 + await student.check_answer(answer)
  233 + q = student.get_current_question()
234 234
235 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') 235 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
236 236
@@ -242,14 +242,24 @@ class LearnApp(object): @@ -242,14 +242,24 @@ class LearnApp(object):
242 starttime=str(q['start_time']), 242 starttime=str(q['start_time']),
243 finishtime=str(q['finish_time']), 243 finishtime=str(q['finish_time']),
244 student_id=uid, 244 student_id=uid,
245 - topic_id=topic))  
246 - logger.debug(f'db insert answer of {q["ref"]}') 245 + topic_id=student.get_current_topic()))
  246 +
  247 + return q
247 248
248 - # save topic if finished 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()
  256 +
  257 + # save topic to database if finished
249 if student.topic_has_finished(): 258 if student.topic_has_finished():
250 - logger.info(f'User "{uid}" finished "{topic}"') 259 + topic: str = student.get_previous_topic()
251 level: float = student.get_topic_level(topic) 260 level: float = student.get_topic_level(topic)
252 date: str = str(student.get_topic_date(topic)) 261 date: str = str(student.get_topic_date(topic))
  262 + logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})')
253 263
254 with self.db_session() as s: 264 with self.db_session() as s:
255 a = s.query(StudentTopic) \ 265 a = s.query(StudentTopic) \
@@ -275,12 +285,6 @@ class LearnApp(object): @@ -275,12 +285,6 @@ class LearnApp(object):
275 return q 285 return q
276 286
277 # ------------------------------------------------------------------------ 287 # ------------------------------------------------------------------------
278 - # get the question to show (current or new one)  
279 - # ------------------------------------------------------------------------  
280 - async def get_question(self, uid: str) -> Optional[Question]:  
281 - return await self.online[uid]['state'].get_question()  
282 -  
283 - # ------------------------------------------------------------------------  
284 # Start course 288 # Start course
285 # ------------------------------------------------------------------------ 289 # ------------------------------------------------------------------------
286 def start_course(self, uid: str, course: str) -> None: 290 def start_course(self, uid: str, course: str) -> None:
@@ -414,11 +418,15 @@ class LearnApp(object): @@ -414,11 +418,15 @@ class LearnApp(object):
414 except Exception: 418 except Exception:
415 raise LearnException(f'Failed to load "{fullpath}"') 419 raise LearnException(f'Failed to load "{fullpath}"')
416 420
  421 + if not isinstance(questions, list):
  422 + msg = f'File "{fullpath}" must be a list of questions'
  423 + raise LearnException(msg)
  424 +
417 # update refs to include topic as prefix. 425 # update refs to include topic as prefix.
418 # refs are required to be unique only within the file. 426 # refs are required to be unique only within the file.
419 # undefined are set to topic:n, where n is the question number 427 # undefined are set to topic:n, where n is the question number
420 # within the file 428 # within the file
421 - localrefs = set() # refs in current file 429 + localrefs: Set[str] = set() # refs in current file
422 for i, q in enumerate(questions): 430 for i, q in enumerate(questions):
423 qref = q.get('ref', str(i)) # ref or number 431 qref = q.get('ref', str(i)) # ref or number
424 if qref in localrefs: 432 if qref in localrefs:
@@ -448,7 +456,7 @@ class LearnApp(object): @@ -448,7 +456,7 @@ class LearnApp(object):
448 456
449 # ------------------------------------------------------------------------ 457 # ------------------------------------------------------------------------
450 def get_login_counter(self, uid: str) -> int: 458 def get_login_counter(self, uid: str) -> int:
451 - return self.online[uid]['counter'] 459 + return int(self.online[uid]['counter'])
452 460
453 # ------------------------------------------------------------------------ 461 # ------------------------------------------------------------------------
454 def get_student_name(self, uid: str) -> str: 462 def get_student_name(self, uid: str) -> str:
@@ -483,7 +491,7 @@ class LearnApp(object): @@ -483,7 +491,7 @@ class LearnApp(object):
483 return self.online[uid]['state'].get_current_course_title() 491 return self.online[uid]['state'].get_current_course_title()
484 492
485 # ------------------------------------------------------------------------ 493 # ------------------------------------------------------------------------
486 - def get_student_course_id(self, uid: str) -> str: 494 + def get_student_course_id(self, uid: str) -> Optional[str]:
487 return self.online[uid]['state'].get_current_course_id() 495 return self.online[uid]['state'].get_current_course_id()
488 496
489 # ------------------------------------------------------------------------ 497 # ------------------------------------------------------------------------
@@ -529,7 +537,7 @@ class LearnApp(object): @@ -529,7 +537,7 @@ class LearnApp(object):
529 # compute topic progress 537 # compute topic progress
530 now = datetime.now() 538 now = datetime.now()
531 goals = self.courses[course_id]['goals'] 539 goals = self.courses[course_id]['goals']
532 - prog = defaultdict(int) 540 + prog: DefaultDict[str, float] = defaultdict(int)
533 541
534 for uid, topic, level, date in student_topics: 542 for uid, topic, level, date in student_topics:
535 if topic in goals: 543 if topic in goals:
aprendizations/questions.py
1 1
2 # python standard library 2 # python standard library
3 import asyncio 3 import asyncio
  4 +from datetime import datetime
4 import random 5 import random
5 import re 6 import re
6 from os import path 7 from os import path
@@ -44,6 +45,10 @@ class Question(dict): @@ -44,6 +45,10 @@ class Question(dict):
44 'files': {}, 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 def correct(self) -> None: 52 def correct(self) -> None:
48 self['comments'] = '' 53 self['comments'] = ''
49 self['grade'] = 0.0 54 self['grade'] = 0.0
@@ -172,7 +177,7 @@ class QuestionRadio(Question): @@ -172,7 +177,7 @@ class QuestionRadio(Question):
172 # x_aver can be exactly 1.0 if all options are right 177 # x_aver can be exactly 1.0 if all options are right
173 if self['discount'] and x_aver != 1.0: 178 if self['discount'] and x_aver != 1.0:
174 x = (x - x_aver) / (1.0 - x_aver) 179 x = (x - x_aver) / (1.0 - x_aver)
175 - self['grade'] = x 180 + self['grade'] = float(x)
176 181
177 182
178 # ============================================================================ 183 # ============================================================================
aprendizations/serve.py
@@ -293,13 +293,18 @@ class QuestionHandler(BaseHandler): @@ -293,13 +293,18 @@ class QuestionHandler(BaseHandler):
293 'alert': 'question-information.html', 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 @tornado.web.authenticated 301 @tornado.web.authenticated
298 async def get(self): 302 async def get(self):
299 logger.debug('[QuestionHandler]') 303 logger.debug('[QuestionHandler]')
300 user = self.current_user 304 user = self.current_user
301 q = await self.learn.get_question(user) 305 q = await self.learn.get_question(user)
302 306
  307 + # show current question
303 if q is not None: 308 if q is not None:
304 qhtml = self.render_string(self.templates[q['type']], 309 qhtml = self.render_string(self.templates[q['type']],
305 question=q, md=md_to_html) 310 question=q, md=md_to_html)
@@ -313,6 +318,7 @@ class QuestionHandler(BaseHandler): @@ -313,6 +318,7 @@ class QuestionHandler(BaseHandler):
313 } 318 }
314 } 319 }
315 320
  321 + # show animated trophy
316 else: 322 else:
317 finished = self.render_string('finished_topic.html') 323 finished = self.render_string('finished_topic.html')
318 response = { 324 response = {
@@ -324,7 +330,11 @@ class QuestionHandler(BaseHandler): @@ -324,7 +330,11 @@ class QuestionHandler(BaseHandler):
324 330
325 self.write(response) 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 @tornado.web.authenticated 338 @tornado.web.authenticated
329 async def post(self) -> None: 339 async def post(self) -> None:
330 user = self.current_user 340 user = self.current_user
@@ -364,6 +374,7 @@ class QuestionHandler(BaseHandler): @@ -364,6 +374,7 @@ class QuestionHandler(BaseHandler):
364 374
365 # --- built response to return 375 # --- built response to return
366 response = {'method': q['status'], 'params': {}} 376 response = {'method': q['status'], 'params': {}}
  377 +
367 if q['status'] == 'right': # get next question in the topic 378 if q['status'] == 'right': # get next question in the topic
368 comments_html = self.render_string( 379 comments_html = self.render_string(
369 'comments-right.html', comments=q['comments'], md=md_to_html) 380 'comments-right.html', comments=q['comments'], md=md_to_html)
aprendizations/student.py
@@ -17,12 +17,21 @@ logger = logging.getLogger(__name__) @@ -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 # state - dict of unlocked topics and their levels 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 class StudentState(object): 36 class StudentState(object):
28 # ======================================================================= 37 # =======================================================================
@@ -50,6 +59,7 @@ class StudentState(object): @@ -50,6 +59,7 @@ class StudentState(object):
50 self.current_course: Optional[str] = None 59 self.current_course: Optional[str] = None
51 self.topic_sequence: List[str] = [] 60 self.topic_sequence: List[str] = []
52 self.current_topic: Optional[str] = None 61 self.current_topic: Optional[str] = None
  62 + # self.previous_topic: Optional[str] = None
53 else: 63 else:
54 logger.debug(f'starting course {course}') 64 logger.debug(f'starting course {course}')
55 self.current_course = course 65 self.current_course = course
@@ -58,7 +68,7 @@ class StudentState(object): @@ -58,7 +68,7 @@ class StudentState(object):
58 68
59 # ------------------------------------------------------------------------ 69 # ------------------------------------------------------------------------
60 # Start a new topic. 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 # current_question: the current question to be presented 72 # current_question: the current question to be presented
63 # ------------------------------------------------------------------------ 73 # ------------------------------------------------------------------------
64 async def start_topic(self, topic: str) -> None: 74 async def start_topic(self, topic: str) -> None:
@@ -74,9 +84,10 @@ class StudentState(object): @@ -74,9 +84,10 @@ class StudentState(object):
74 logger.debug(f'is locked "{topic}"') 84 logger.debug(f'is locked "{topic}"')
75 return 85 return
76 86
  87 + self.previous_topic: Optional[str] = None
  88 +
77 # choose k questions 89 # choose k questions
78 self.current_topic = topic 90 self.current_topic = topic
79 - # self.current_question = None  
80 self.correct_answers = 0 91 self.correct_answers = 0
81 self.wrong_answers = 0 92 self.wrong_answers = 0
82 t = self.deps.nodes[topic] 93 t = self.deps.nodes[topic]
@@ -96,30 +107,17 @@ class StudentState(object): @@ -96,30 +107,17 @@ class StudentState(object):
96 self.next_question() 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 # corrects current question 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 q = self.current_question 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 if q['grade'] > 0.999: 122 if q['grade'] > 0.999:
125 self.correct_answers += 1 123 self.correct_answers += 1
@@ -134,13 +132,17 @@ class StudentState(object): @@ -134,13 +132,17 @@ class StudentState(object):
134 q['status'] = 'wrong' 132 q['status'] = 'wrong'
135 133
136 logger.debug(f'ref = {q["ref"]}, status = {q["status"]}') 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 async def get_question(self) -> Optional[Question]: 140 async def get_question(self) -> Optional[Question]:
143 q = self.current_question 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 logger.debug(f'{q["ref"]} status = {q["status"]}') 146 logger.debug(f'{q["ref"]} status = {q["status"]}')
145 147
146 if q['status'] == 'right': 148 if q['status'] == 'right':
@@ -151,10 +153,6 @@ class StudentState(object): @@ -151,10 +153,6 @@ class StudentState(object):
151 new_question = await self.factory[q['ref']].gen_async() 153 new_question = await self.factory[q['ref']].gen_async()
152 self.questions.append(new_question) 154 self.questions.append(new_question)
153 self.next_question() 155 self.next_question()
154 - # elif q['status'] == 'new':  
155 - # pass  
156 - # elif q['status'] == 'try_again':  
157 - # pass  
158 156
159 return self.current_question 157 return self.current_question
160 158
@@ -172,7 +170,25 @@ class StudentState(object): @@ -172,7 +170,25 @@ class StudentState(object):
172 q['start_time'] = datetime.now() 170 q['start_time'] = datetime.now()
173 q['tries'] = q.get('max_tries', t['max_tries']) 171 q['tries'] = q.get('max_tries', t['max_tries'])
174 q['status'] = 'new' 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 # Update proficiency level of the topics using a forgetting factor 194 # Update proficiency level of the topics using a forgetting factor
@@ -211,7 +227,7 @@ class StudentState(object): @@ -211,7 +227,7 @@ class StudentState(object):
211 # ======================================================================== 227 # ========================================================================
212 228
213 def topic_has_finished(self) -> bool: 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 # compute recommended sequence of topics ['a', 'b', ...] 233 # compute recommended sequence of topics ['a', 'b', ...]
@@ -238,8 +254,12 @@ class StudentState(object): @@ -238,8 +254,12 @@ class StudentState(object):
238 return self.current_topic 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 def get_current_course_id(self) -> Optional[str]: 265 def get_current_course_id(self) -> Optional[str]:
@@ -269,7 +289,7 @@ class StudentState(object): @@ -269,7 +289,7 @@ class StudentState(object):
269 289
270 # ------------------------------------------------------------------------ 290 # ------------------------------------------------------------------------
271 def get_topic_level(self, topic: str) -> float: 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 def get_topic_date(self, topic: str): 295 def get_topic_date(self, topic: str):