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
BUGS.md
... ... @@ -31,6 +31,7 @@
31 31  
32 32 # FIXED
33 33  
  34 +- CRITICAL nao esta a guardar o progresso na base de dados.
34 35 - mesma ref no mesmo ficheiro não é detectado.
35 36 - enter nas respostas mostra json
36 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 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,6 +88,7 @@ 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 94 raise LearnException(f'Goal "{goal}" from course "{c}" '
... ... @@ -228,9 +229,8 @@ class LearnApp(object):
228 229 # ------------------------------------------------------------------------
229 230 async def check_answer(self, uid: str, answer) -> Question:
230 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 235 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
236 236  
... ... @@ -242,14 +242,24 @@ class LearnApp(object):
242 242 starttime=str(q['start_time']),
243 243 finishtime=str(q['finish_time']),
244 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 258 if student.topic_has_finished():
250   - logger.info(f'User "{uid}" finished "{topic}"')
  259 + topic: str = student.get_previous_topic()
251 260 level: float = student.get_topic_level(topic)
252 261 date: str = str(student.get_topic_date(topic))
  262 + logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})')
253 263  
254 264 with self.db_session() as s:
255 265 a = s.query(StudentTopic) \
... ... @@ -275,12 +285,6 @@ class LearnApp(object):
275 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 288 # Start course
285 289 # ------------------------------------------------------------------------
286 290 def start_course(self, uid: str, course: str) -> None:
... ... @@ -414,11 +418,15 @@ class LearnApp(object):
414 418 except Exception:
415 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 425 # update refs to include topic as prefix.
418 426 # refs are required to be unique only within the file.
419 427 # undefined are set to topic:n, where n is the question number
420 428 # within the file
421   - localrefs = set() # refs in current file
  429 + localrefs: Set[str] = set() # refs in current file
422 430 for i, q in enumerate(questions):
423 431 qref = q.get('ref', str(i)) # ref or number
424 432 if qref in localrefs:
... ... @@ -448,7 +456,7 @@ class LearnApp(object):
448 456  
449 457 # ------------------------------------------------------------------------
450 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 462 def get_student_name(self, uid: str) -> str:
... ... @@ -483,7 +491,7 @@ class LearnApp(object):
483 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 495 return self.online[uid]['state'].get_current_course_id()
488 496  
489 497 # ------------------------------------------------------------------------
... ... @@ -529,7 +537,7 @@ class LearnApp(object):
529 537 # compute topic progress
530 538 now = datetime.now()
531 539 goals = self.courses[course_id]['goals']
532   - prog = defaultdict(int)
  540 + prog: DefaultDict[str, float] = defaultdict(int)
533 541  
534 542 for uid, topic, level, date in student_topics:
535 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
... ... @@ -172,7 +177,7 @@ class QuestionRadio(Question):
172 177 # x_aver can be exactly 1.0 if all options are right
173 178 if self['discount'] and x_aver != 1.0:
174 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 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):
... ...