Commit a9131008a6eb2c630a1b6808c6fbec76a37e29d5

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

- allow chapters to not have any questions.yaml file

- fixed some mypy errors
- don't show topic dependencies that are not goals if already done
- allow admin to reload topic and regenerate QFactory
BUGS.md
1 1  
2 2 # BUGS
3 3  
4   -- ir para inicio da pagina quando le nova pergunta.
  4 +- goals se forem do tipo chapter deve importar todas as dependencias do chapter (e não mostrar chapters?).
5 5 - nao esta a seguir o max_tries definido no ficheiro de dependencias.
6 6 - devia mostrar timeout para o aluno saber a razao.
7 7 - permitir configuracao para escolher entre static files locais ou remotos
8 8 - templates question-*.html tem input hidden question_ref que não é usado. remover?
9 9 - shift-enter não está a funcionar
10 10 - default prefix should be obtained from each course (yaml conf)?
11   -- initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
12 11  
13 12 # TODO
14 13  
  14 +- alterar tabelas para incluir email de recuperacao de password (e outros avisos)
15 15 - registar last_seen e remover os antigos de cada vez que houver um login.
16 16 - indicar qtos topicos faltam (>=50%) para terminar o curso.
17 17 - ao fim de 3 tentativas com password errada, envia email com nova password.
... ... @@ -32,6 +32,9 @@
32 32  
33 33 # FIXED
34 34  
  35 +- initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
  36 +- dependencias que não são goals de um curso, só devem aparecer se ainda não tiverem sido feitas.
  37 +- ir para inicio da pagina quando le nova pergunta.
35 38 - CRITICAL nao esta a guardar o progresso na base de dados.
36 39 - mesma ref no mesmo ficheiro não é detectado.
37 40 - enter nas respostas mostra json
... ...
aprendizations/__init__.py
... ... @@ -30,7 +30,7 @@ are progressively uncovered as the students progress.
30 30 '''
31 31  
32 32 APP_NAME = 'aprendizations'
33   -APP_VERSION = '2020.01.dev1'
  33 +APP_VERSION = '2020.01.dev2'
34 34 APP_DESCRIPTION = __doc__
35 35  
36 36 __author__ = 'Miguel Barão'
... ...
aprendizations/learnapp.py
... ... @@ -36,6 +36,16 @@ class DatabaseUnusableError(LearnException):
36 36  
37 37 # ============================================================================
38 38 # LearnApp - application logic
  39 +#
  40 +# self.deps - networkx topic dependencies
  41 +# self.courses - dict {course_id: {'title': ...,
  42 +# 'description': ...,
  43 +# 'goals': ...,}, ...}
  44 +# self.factory = dict {qref: QFactory()}
  45 +# self.online - dict {student_id: {'number': ...,
  46 +# 'name': ...,
  47 +# 'state': StudentState(),
  48 +# 'counter': ...}, ...}
39 49 # ============================================================================
40 50 class LearnApp(object):
41 51 # ------------------------------------------------------------------------
... ... @@ -67,7 +77,12 @@ class LearnApp(object):
67 77 self.db_setup(db) # setup database and check students
68 78 self.online: Dict[str, Dict] = dict() # online students
69 79  
70   - config: Dict[str, Any] = load_yaml(courses)
  80 + try:
  81 + config: Dict[str, Any] = load_yaml(courses)
  82 + except Exception:
  83 + msg = f'Failed to load yaml file "{courses}"'
  84 + logger.error(msg)
  85 + raise LearnException(msg)
71 86  
72 87 # --- topic dependencies are shared between all courses
73 88 self.deps = nx.DiGraph(prefix=prefix)
... ... @@ -78,7 +93,6 @@ class LearnApp(object):
78 93 logger.info(f'{len(t):>6} topics in {courses}')
79 94 for f in config.get('topics_from', []):
80 95 c = load_yaml(f) # course configuration
81   -
82 96 # FIXME set defaults??
83 97 logger.info(f'{len(c["topics"]):>6} topics imported from {f}')
84 98 self.populate_graph(c)
... ... @@ -91,8 +105,9 @@ class LearnApp(object):
91 105 d.setdefault('title', '') # course title undefined
92 106 for goal in d['goals']:
93 107 if goal not in self.deps.nodes():
94   - raise LearnException(f'Goal "{goal}" from course "{c}" '
95   - ' does not exist')
  108 + msg = f'Goal "{goal}" from course "{c}" does not exist'
  109 + logger.error(msg)
  110 + raise LearnException(msg)
96 111  
97 112 # --- factory is a dict with question generators for all topics
98 113 self.factory: Dict[str, QFactory] = self.make_factory()
... ... @@ -230,7 +245,7 @@ class LearnApp(object):
230 245 async def check_answer(self, uid: str, answer) -> Question:
231 246 student = self.online[uid]['state']
232 247 await student.check_answer(answer)
233   - q = student.get_current_question()
  248 + q: Question = student.get_current_question()
234 249  
235 250 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
236 251  
... ... @@ -287,21 +302,25 @@ class LearnApp(object):
287 302 # ------------------------------------------------------------------------
288 303 # Start course
289 304 # ------------------------------------------------------------------------
290   - def start_course(self, uid: str, course: str) -> None:
  305 + def start_course(self, uid: str, course_id: str) -> None:
291 306 student = self.online[uid]['state']
292 307 try:
293   - student.start_course(course)
294   - except Exception as e:
295   - logger.warning(f'"{uid}" could not start course "{course}": {e}')
  308 + student.start_course(course_id)
  309 + except Exception:
  310 + logger.warning(f'"{uid}" could not start course "{course_id}"')
296 311 raise
297 312 else:
298   - logger.info(f'User "{uid}" started course "{course}"')
  313 + logger.info(f'User "{uid}" started course "{course_id}"')
299 314  
300 315 # ------------------------------------------------------------------------
301 316 # Start new topic
302 317 # ------------------------------------------------------------------------
303 318 async def start_topic(self, uid: str, topic: str) -> None:
304 319 student = self.online[uid]['state']
  320 + if uid == '0':
  321 + logger.warning(f'Reloading "{topic}"')
  322 + self.factory.update(self.factory_for(topic))
  323 +
305 324 try:
306 325 await student.start_topic(topic)
307 326 except Exception as e:
... ... @@ -393,66 +412,81 @@ class LearnApp(object):
393 412  
394 413 # ------------------------------------------------------------------------
395 414 # Buils dictionary of question factories
  415 + # - visits each topic in the graph,
  416 + # - adds factory for each topic.
396 417 # ------------------------------------------------------------------------
397 418 def make_factory(self) -> Dict[str, QFactory]:
398 419  
399 420 logger.info('Building questions factory:')
400   - factory = {}
  421 + factory = dict()
401 422 g = self.deps
402 423 for tref in g.nodes():
403   - t = g.nodes[tref]
  424 + factory.update(self.factory_for(tref))
404 425  
405   - # load questions as list of dicts
406   - topicpath: str = path.join(g.graph['prefix'], tref)
407   - try:
408   - fullpath: str = path.join(topicpath, t['file'])
409   - except Exception:
410   - msg1 = f'Invalid topic "{tref}"'
411   - msg2 = f'Check dependencies of {", ".join(g.successors(tref))}'
412   - raise LearnException(f'{msg1}. {msg2}')
  426 + logger.info(f'Factory has {len(factory)} questions')
  427 + return factory
413 428  
414   - logger.debug(f' Loading {fullpath}')
415   - try:
416   - questions: List[QDict] = load_yaml(fullpath)
417   - except Exception:
  429 + # ------------------------------------------------------------------------
  430 + # makes factory for a single topic
  431 + # ------------------------------------------------------------------------
  432 + def factory_for(self, tref: str) -> Dict[str, QFactory]:
  433 + factory: Dict[str, QFactory] = {}
  434 + g = self.deps
  435 + t = g.nodes[tref] # get node
  436 +
  437 + # load questions as list of dicts
  438 + topicpath: str = path.join(g.graph['prefix'], tref)
  439 + try:
  440 + fullpath: str = path.join(topicpath, t['file'])
  441 + except Exception:
  442 + msg1 = f'Invalid topic "{tref}"'
  443 + msg2 = f'Check dependencies of {", ".join(g.successors(tref))}'
  444 + raise LearnException(f'{msg1}. {msg2}')
  445 +
  446 + logger.debug(f' Loading {fullpath}')
  447 + try:
  448 + questions: List[QDict] = load_yaml(fullpath)
  449 + except Exception:
  450 + if t['type'] == 'chapter':
  451 + return factory # chapters may have no "questions"
  452 + else:
418 453 msg = f'Failed to load "{fullpath}"'
419 454 logger.error(msg)
420 455 raise LearnException(msg)
421 456  
422   - if not isinstance(questions, list):
423   - msg = f'File "{fullpath}" must be a list of questions'
  457 + if not isinstance(questions, list):
  458 + msg = f'File "{fullpath}" must be a list of questions'
  459 + raise LearnException(msg)
  460 +
  461 + # update refs to include topic as prefix.
  462 + # refs are required to be unique only within the file.
  463 + # undefined are set to topic:n, where n is the question number
  464 + # within the file
  465 + localrefs: Set[str] = set() # refs in current file
  466 + for i, q in enumerate(questions):
  467 + qref = q.get('ref', str(i)) # ref or number
  468 + if qref in localrefs:
  469 + msg = f'Duplicate ref "{qref}" in "{topicpath}"'
424 470 raise LearnException(msg)
  471 + localrefs.add(qref)
425 472  
426   - # update refs to include topic as prefix.
427   - # refs are required to be unique only within the file.
428   - # undefined are set to topic:n, where n is the question number
429   - # within the file
430   - localrefs: Set[str] = set() # refs in current file
431   - for i, q in enumerate(questions):
432   - qref = q.get('ref', str(i)) # ref or number
433   - if qref in localrefs:
434   - msg = f'Duplicate ref "{qref}" in "{topicpath}"'
435   - raise LearnException(msg)
436   - localrefs.add(qref)
437   -
438   - q['ref'] = f'{tref}:{qref}'
439   - q['path'] = topicpath
440   - q.setdefault('append_wrong', t['append_wrong'])
  473 + q['ref'] = f'{tref}:{qref}'
  474 + q['path'] = topicpath
  475 + q.setdefault('append_wrong', t['append_wrong'])
441 476  
442   - # if questions are left undefined, include all.
443   - if not t['questions']:
444   - t['questions'] = [q['ref'] for q in questions]
  477 + # if questions are left undefined, include all.
  478 + if not t['questions']:
  479 + t['questions'] = [q['ref'] for q in questions]
445 480  
446   - t['choose'] = min(t['choose'], len(t['questions']))
  481 + t['choose'] = min(t['choose'], len(t['questions']))
447 482  
448   - for q in questions:
449   - if q['ref'] in t['questions']:
450   - factory[q['ref']] = QFactory(q)
451   - logger.debug(f' + {q["ref"]}')
  483 + for q in questions:
  484 + if q['ref'] in t['questions']:
  485 + factory[q['ref']] = QFactory(q)
  486 + logger.debug(f' + {q["ref"]}')
452 487  
453   - logger.info(f'{len(t["questions"]):6} questions in {tref}')
  488 + logger.info(f'{len(t["questions"]):6} questions in {tref}')
454 489  
455   - logger.info(f'Factory has {len(factory)} questions')
456 490 return factory
457 491  
458 492 # ------------------------------------------------------------------------
... ... @@ -469,47 +503,53 @@ class LearnApp(object):
469 503  
470 504 # ------------------------------------------------------------------------
471 505 def get_student_progress(self, uid: str) -> float:
472   - return self.online[uid]['state'].get_topic_progress()
  506 + return float(self.online[uid]['state'].get_topic_progress())
473 507  
474 508 # ------------------------------------------------------------------------
475 509 def get_current_question(self, uid: str) -> Optional[Question]:
476   - return self.online[uid]['state'].get_current_question()
  510 + q: Optional[Question] = self.online[uid]['state'].get_current_question()
  511 + return q
477 512  
478 513 # ------------------------------------------------------------------------
479 514 def get_current_question_id(self, uid: str) -> str:
480   - return self.online[uid]['state'].get_current_question()['qid']
  515 + return str(self.online[uid]['state'].get_current_question()['qid'])
481 516  
482 517 # ------------------------------------------------------------------------
483 518 def get_student_question_type(self, uid: str) -> str:
484   - return self.online[uid]['state'].get_current_question()['type']
  519 + return str(self.online[uid]['state'].get_current_question()['type'])
485 520  
486 521 # ------------------------------------------------------------------------
487 522 def get_student_topic(self, uid: str) -> str:
488   - return self.online[uid]['state'].get_current_topic()
  523 + return str(self.online[uid]['state'].get_current_topic())
489 524  
490 525 # ------------------------------------------------------------------------
491 526 def get_student_course_title(self, uid: str) -> str:
492   - return self.online[uid]['state'].get_current_course_title()
  527 + return str(self.online[uid]['state'].get_current_course_title())
493 528  
494 529 # ------------------------------------------------------------------------
495   - def get_student_course_id(self, uid: str) -> Optional[str]:
496   - return self.online[uid]['state'].get_current_course_id()
  530 + def get_current_course_id(self, uid: str) -> Optional[str]:
  531 + cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
  532 + return cid
497 533  
498 534 # ------------------------------------------------------------------------
499 535 def get_topic_name(self, ref: str) -> str:
500   - return self.deps.nodes[ref]['name']
  536 + return str(self.deps.nodes[ref]['name'])
501 537  
502 538 # ------------------------------------------------------------------------
503 539 def get_current_public_dir(self, uid: str) -> str:
504   - topic: str = self.online[uid]['state'].get_current_topic() # FIXME returns None if its the last question in the topic
  540 + topic: str = self.online[uid]['state'].get_current_topic()
505 541 prefix: str = self.deps.graph['prefix']
506 542 return path.join(prefix, topic, 'public')
507 543  
508 544 # ------------------------------------------------------------------------
509   - def get_courses(self, uid: str) -> Dict:
  545 + def get_courses(self) -> Dict[str, Dict[str, Any]]:
510 546 return self.courses
511 547  
512 548 # ------------------------------------------------------------------------
  549 + def get_course(self, course_id: str) -> Dict[str, Any]:
  550 + return self.courses[course_id]
  551 +
  552 + # ------------------------------------------------------------------------
513 553 def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]:
514 554  
515 555 logger.info(f'User "{uid}" get rankings for {course_id}')
... ...
aprendizations/main.py
... ... @@ -179,10 +179,10 @@ def main():
179 179 sep='\n')
180 180 sys.exit(1)
181 181 except LearnException as e:
182   - logging.critical(e)
  182 + logging.critical('Failed to start backend')
183 183 sys.exit(1)
184 184 except Exception:
185   - logging.critical('Failed to start backend.')
  185 + logging.critical('Unknown error')
186 186 sys.exit(1)
187 187 else:
188 188 logging.info('LearnApp started')
... ...
aprendizations/serve.py
... ... @@ -96,7 +96,7 @@ class RankingsHandler(BaseHandler):
96 96 @tornado.web.authenticated
97 97 def get(self):
98 98 uid = self.current_user
99   - current_course = self.learn.get_student_course_id(uid)
  99 + current_course = self.learn.get_current_course_id(uid)
100 100 course_id = self.get_query_argument('course', default=current_course)
101 101 rankings = self.learn.get_rankings(uid, course_id)
102 102 self.render('rankings.html',
... ... @@ -195,7 +195,7 @@ class CoursesHandler(BaseHandler):
195 195 appname=APP_NAME,
196 196 uid=uid,
197 197 name=self.learn.get_student_name(uid),
198   - courses=self.learn.get_courses(uid),
  198 + courses=self.learn.get_courses(),
199 199 )
200 200  
201 201  
... ... @@ -205,13 +205,11 @@ class CoursesHandler(BaseHandler):
205 205 # ----------------------------------------------------------------------------
206 206 class CourseHandler(BaseHandler):
207 207 @tornado.web.authenticated
208   - def get(self, course):
  208 + def get(self, course_id):
209 209 uid = self.current_user
210   - if course == '':
211   - course = self.learn.get_student_course_id(uid)
212 210  
213 211 try:
214   - self.learn.start_course(uid, course)
  212 + self.learn.start_course(uid, course_id)
215 213 except KeyError:
216 214 self.redirect('/courses')
217 215  
... ... @@ -220,8 +218,8 @@ class CourseHandler(BaseHandler):
220 218 uid=uid,
221 219 name=self.learn.get_student_name(uid),
222 220 state=self.learn.get_student_state(uid),
223   - course_title=self.learn.get_student_course_title(uid),
224   - course_id=self.learn.get_student_course_id(uid),
  221 + course_id=course_id,
  222 + course=self.learn.get_course(course_id)
225 223 )
226 224  
227 225  
... ... @@ -244,7 +242,7 @@ class TopicHandler(BaseHandler):
244 242 uid=uid,
245 243 name=self.learn.get_student_name(uid),
246 244 # course_title=self.learn.get_student_course_title(uid),
247   - course_id=self.learn.get_student_course_id(uid),
  245 + course_id=self.learn.get_current_course_id(uid),
248 246 )
249 247  
250 248  
... ...
aprendizations/student.py
... ... @@ -75,12 +75,12 @@ class StudentState(object):
75 75 logger.debug(f'start topic "{topic}"')
76 76  
77 77 # avoid regenerating questions in the middle of the current topic
78   - if self.current_topic == topic:
  78 + if self.current_topic == topic and self.uid != '0':
79 79 logger.info('Restarting current topic is not allowed.')
80 80 return
81 81  
82 82 # do not allow locked topics
83   - if self.is_locked(topic):
  83 + if self.is_locked(topic) and self.uid != '0':
84 84 logger.debug(f'is locked "{topic}"')
85 85 return
86 86  
... ... @@ -236,9 +236,17 @@ class StudentState(object):
236 236 G = self.deps
237 237 ts = set(goals)
238 238 for t in goals:
239   - ts.update(nx.ancestors(G, t))
240   - # FIXME filter by level done, only level < 50% are included
241   - tl = list(nx.topological_sort(G.subgraph(ts)))
  239 + ts.update(nx.ancestors(G, t)) # include dependencies not in goals
  240 +
  241 + todo = []
  242 + for t in ts:
  243 + level = self.state[t]['level'] if t in self.state else 0.0
  244 + min_level = G.nodes[t]['min_level']
  245 + if t in goals or level < min_level:
  246 + todo.append(t)
  247 +
  248 + # FIXME topological sort is a poor way to sort topics
  249 + tl = list(nx.topological_sort(G.subgraph(todo)))
242 250  
243 251 # sort with unlocked first
244 252 unlocked = [t for t in tl if t in self.state]
... ...
aprendizations/templates/maintopics-table.html
... ... @@ -60,7 +60,7 @@
60 60  
61 61 <div id="notifications"></div>
62 62  
63   - <h4>{{ course_title }}</h4>
  63 + <h4>{{ course['title'] }}</h4>
64 64  
65 65 <table class="table table-hover">
66 66 <thead class="">
... ...
package-lock.json
... ... @@ -8,14 +8,14 @@
8 8 "integrity": "sha512-vKDJUuE2GAdBERaQWmmtsciAMzjwNrROXA5KTGSZvayAsmuTGjam5z6QNqNPCwDfVljLWuov1nEC3mEQf/n6fQ=="
9 9 },
10 10 "codemirror": {
11   - "version": "5.50.0",
12   - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.50.0.tgz",
13   - "integrity": "sha512-32LAmGcBNhKtJP4WGgkcaCVQDyChAyaWA6jasg778ziZzo3PWBuhpAQIJMO8//Id45RoaLyXjuhcRUBoS8Vg+Q=="
  11 + "version": "5.51.0",
  12 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.51.0.tgz",
  13 + "integrity": "sha512-vyuYYRv3eXL0SCuZA4spRFlKNzQAewHcipRQCOKgRy7VNAvZxTKzbItdbCl4S5AgPZ5g3WkHp+ibWQwv9TLG7Q=="
14 14 },
15 15 "mdbootstrap": {
16   - "version": "4.11.0",
17   - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.11.0.tgz",
18   - "integrity": "sha512-3yhRRo8UQDqRgeEutSpx9jIECzkyPebOq/oYsG2TLAmXVmujDBb+OoTW6+yZ1MtaQZCu8AF8D1/pM9Y8sLj3uA=="
  16 + "version": "4.12.0",
  17 + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.12.0.tgz",
  18 + "integrity": "sha512-+X4x63tE96zpVOcRlVUGdcR65M9Ud+/l1TvdmcwUjEGo3ktn9TO3e6S3DBLTvchO9U5eKuJh/MIWIGac7+569g=="
19 19 }
20 20 }
21 21 }
... ...
package.json
... ... @@ -3,8 +3,8 @@
3 3 "email": "mjsb@uevora.pt",
4 4 "dependencies": {
5 5 "@fortawesome/fontawesome-free": "^5.12.0",
6   - "codemirror": "^5.50.0",
7   - "mdbootstrap": "^4.11.0"
  6 + "codemirror": "^5.51.0",
  7 + "mdbootstrap": "^4.12.0"
8 8 },
9 9 "private": true
10 10 }
... ...