Commit a9131008a6eb2c630a1b6808c6fbec76a37e29d5
1 parent
b2444bac
Exists in
master
and in
1 other branch
- 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
Showing
9 changed files
with
139 additions
and
90 deletions
Show diff stats
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
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
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