Commit 289991dc23e4ce5d43a196d5da043499ee202fc9

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

- several fixes, including fixed use of deprecated networkx functions.

- supports several courses.
- replaced 'topics' by 'goals' in the courses.yaml definition.
.gitignore
... ... @@ -3,4 +3,7 @@
3 3 /aprendizations/__pycache__/
4 4 /demo/students.db
5 5 /node_modules/
6   -/.mypy_cache/
7 6 \ No newline at end of file
  7 +/.mypy_cache/
  8 +.DS_Store
  9 +demo/.DS_Store
  10 +demo/solar_system/.DS_Store
... ...
BUGS.md
1 1  
2 2 # BUGS
3 3  
4   -- não suporta definition lists em markdown
  4 +- obter rankings por curso GET course=course_id
  5 +- indicar qtos topicos faltam (>=50%) para terminar o curso.
  6 +- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.
5 7 - double click submits twice.
6 8 - nao esta a seguir o max_tries definido no ficheiro de dependencias.
7   -- obter rankings por curso GET course=course_id
8 9 - impedir que quando students.db não é encontrado, crie um ficheiro vazio.
9 10 - classificacoes so devia mostrar os que ja fizeram alguma coisa
10 11 - QFactory.generate() devia fazer run da gen_async, ou remover.
... ...
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 = '2019.07.dev3'
  33 +APP_VERSION = '2019.10.dev1'
34 34 APP_DESCRIPTION = __doc__
35 35  
36 36 __author__ = 'Miguel Barão'
... ...
aprendizations/learnapp.py
... ... @@ -67,18 +67,24 @@ class LearnApp(object):
67 67  
68 68 config: Dict[str, Any] = load_yaml(courses)
69 69  
70   - # courses dict
  70 + # --- courses dict
71 71 self.courses = config['courses']
72 72 logger.info(f'Courses: {", ".join(self.courses.keys())}')
73 73  
74   - # topic dependencies are shared between all courses
  74 + # --- topic dependencies are shared between all courses
75 75 self.deps = nx.DiGraph(prefix=prefix)
76 76 logger.info('Populating graph:')
77   - for f in config['deps']:
78   - self.populate_graph(f)
  77 +
  78 + t = config.get('topics', {}) # topics defined directly in courses file
  79 + self.populate_graph(t)
  80 + logger.info(f'{len(t):>6} topics in {courses}')
  81 + for f in config.get('topics_from', []):
  82 + c = load_yaml(f) # course configuration
  83 + logger.info(f'{len(c["topics"]):>6} topics from {f}')
  84 + self.populate_graph(c)
79 85 logger.info(f'Graph has {len(self.deps)} topics')
80 86  
81   - # factory is a dict with question generators for all topics
  87 + # --- factory is a dict with question generators for all topics
82 88 self.factory: Dict[str, QFactory] = self.make_factory()
83 89  
84 90 # if graph has topics that are not in the database, add them
... ... @@ -322,29 +328,30 @@ class LearnApp(object):
322 328 # Populates a digraph.
323 329 #
324 330 # Nodes are the topic references e.g. 'my/topic'
325   - # g.node['my/topic']['name'] name of the topic
326   - # g.node['my/topic']['questions'] list of question refs
  331 + # g.nodes['my/topic']['name'] name of the topic
  332 + # g.nodes['my/topic']['questions'] list of question refs
327 333 #
328 334 # Edges are obtained from the deps defined in the YAML file for each topic.
329 335 # ------------------------------------------------------------------------
330   - def populate_graph(self, conffile: str) -> None:
331   - config: Dict[str, Any] = load_yaml(conffile) # course configuration
332   -
333   - # default attributes that apply to the topics
334   - default_file: str = config.get('file', 'questions.yaml')
335   - default_shuffle_questions: bool = config.get('shuffle_questions', True)
336   - default_choose: int = config.get('choose', 9999)
337   - default_forgetting_factor: float = config.get('forgetting_factor', 1.0)
338   - default_maxtries: int = config.get('max_tries', 1)
339   - default_append_wrong: bool = config.get('append_wrong', True)
340   - default_min_level: float = config.get('min_level', 0.01) # to unlock
  336 + def populate_graph(self, config: Dict[str, Any]) -> None:
  337 + g = self.deps # dependency graph
  338 + defaults = {
  339 + 'type': 'topic',
  340 + 'file': 'questions.yaml',
  341 + 'shuffle_questions': True,
  342 + 'choose': 9999,
  343 + 'forgetting_factor': 1.0, # no forgetting
  344 + 'max_tries': 1, # in every question
  345 + 'append_wrong': True,
  346 + 'min_level': 0.01, # to unlock topic
  347 + }
  348 + defaults.update(config.get('defaults', {}))
341 349  
342 350 # iterate over topics and populate graph
343 351 topics: Dict[str, Dict] = config.get('topics', {})
344   - g = self.deps # dependency graph
345   -
346 352 g.add_nodes_from(topics.keys())
347 353 for tref, attr in topics.items():
  354 + logger.debug(f' + {tref}...')
348 355 for d in attr.get('deps', []):
349 356 if d not in g.nodes():
350 357 logger.error(f'Topic "{tref}" depends on "{d}" but it '
... ... @@ -353,22 +360,15 @@ class LearnApp(object):
353 360 else:
354 361 g.add_edge(d, tref)
355 362  
356   - t = g.node[tref] # get current topic node
357   - t['type'] = attr.get('type', 'topic')
  363 + t = g.nodes[tref] # get current topic node
358 364 t['name'] = attr.get('name', tref)
359   - t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic
360   - t['file'] = attr.get('file', default_file) # questions.yaml
361   - t['shuffle_questions'] = attr.get('shuffle_questions',
362   - default_shuffle_questions)
363   - t['max_tries'] = attr.get('max_tries', default_maxtries)
364   - t['forgetting_factor'] = attr.get('forgetting_factor',
365   - default_forgetting_factor)
366   - t['min_level'] = attr.get('min_level', default_min_level)
367   - t['choose'] = attr.get('choose', default_choose)
368   - t['append_wrong'] = attr.get('append_wrong', default_append_wrong)
369 365 t['questions'] = attr.get('questions', [])
370 366  
371   - logger.info(f'{len(topics):>6} topics in {conffile}')
  367 + for k, default in defaults.items():
  368 + t[k] = attr.get(k, default)
  369 +
  370 + t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic
  371 +
372 372  
373 373 # ========================================================================
374 374 # methods that do not change state (pure functions)
... ... @@ -378,12 +378,11 @@ class LearnApp(object):
378 378 # Buils dictionary of question factories
379 379 # ------------------------------------------------------------------------
380 380 def make_factory(self) -> Dict[str, QFactory]:
381   -
382 381 logger.info('Building questions factory:')
383 382 factory: Dict[str, QFactory] = {}
384 383 g = self.deps
385 384 for tref in g.nodes():
386   - t = g.node[tref]
  385 + t = g.nodes[tref]
387 386  
388 387 # load questions as list of dicts
389 388 topicpath: str = path.join(g.graph['prefix'], tref)
... ... @@ -457,13 +456,8 @@ class LearnApp(object):
457 456 return self.online[uid]['state'].get_current_course_id()
458 457  
459 458 # ------------------------------------------------------------------------
460   - # def get_title(self) -> str:
461   - # # return self.deps.graph.get('title', '')
462   - # return self.
463   -
464   - # ------------------------------------------------------------------------
465 459 def get_topic_name(self, ref: str) -> str:
466   - return self.deps.node[ref]['name']
  460 + return self.deps.nodes[ref]['name']
467 461  
468 462 # ------------------------------------------------------------------------
469 463 def get_current_public_dir(self, uid: str) -> str:
... ...
aprendizations/static/js/topic.js
... ... @@ -67,13 +67,13 @@ function new_question(type, question, tries, progress) {
67 67 });
68 68 }
69 69  
70   - // prevent enter submit.
71   - //input:text, input:radio, input:checkbox
72   - $("body").keyup(function (e) {
73   - if (e.keyCode == 13 && e.shiftKey) {
74   - $("#submit").click();
  70 + // disable enter in some question types (prevent submission on enter)
  71 + $("input:text, input:radio, input:checkbox").keydown(function (e) {
  72 + if (e.keyCode == 13 || e.keyCode == 169) {
  73 + e.preventDefault();
75 74 return false;
76   - }});
  75 + }
  76 + });
77 77 }
78 78  
79 79  
... ...
aprendizations/student.py
... ... @@ -53,7 +53,7 @@ class StudentState(object):
53 53 else:
54 54 logger.debug(f'starting course {course}')
55 55 self.current_course = course
56   - topics = self.courses[course]['topics']
  56 + topics = self.courses[course]['goals']
57 57 self.topic_sequence = self.recommend_topic_sequence(topics)
58 58  
59 59 # ------------------------------------------------------------------------
... ... @@ -79,7 +79,7 @@ class StudentState(object):
79 79 self.correct_answers = 0
80 80 self.wrong_answers = 0
81 81  
82   - t = self.deps.node[topic]
  82 + t = self.deps.nodes[topic]
83 83 k = t['choose']
84 84 if t['shuffle_questions']:
85 85 questions = random.sample(t['questions'], k=k)
... ... @@ -174,7 +174,7 @@ class StudentState(object):
174 174 now = datetime.now()
175 175 for tref, s in self.state.items():
176 176 dt = now - s['date']
177   - forgetting_factor = self.deps.node[tref]['forgetting_factor']
  177 + forgetting_factor = self.deps.nodes[tref]['forgetting_factor']
178 178 s['level'] *= forgetting_factor ** dt.days # forgetting factor
179 179  
180 180 # ------------------------------------------------------------------------
... ... @@ -184,7 +184,7 @@ class StudentState(object):
184 184 for topic in self.deps.nodes():
185 185 if topic not in self.state: # if locked
186 186 pred = self.deps.predecessors(topic)
187   - min_level = self.deps.node[topic]['min_level']
  187 + min_level = self.deps.nodes[topic]['min_level']
188 188 if all(d in self.state and self.state[d]['level'] > min_level
189 189 for d in pred): # all deps are greater than min_level
190 190  
... ... @@ -206,12 +206,12 @@ class StudentState(object):
206 206 # ------------------------------------------------------------------------
207 207 # compute recommended sequence of topics ['a', 'b', ...]
208 208 # ------------------------------------------------------------------------
209   - def recommend_topic_sequence(self, targets: List[str] = []) -> List[str]:
  209 + def recommend_topic_sequence(self, goals: List[str] = []) -> List[str]:
210 210 G = self.deps
211   - ts = set(targets)
212   - for t in targets:
  211 + ts = set(goals)
  212 + for t in goals:
213 213 ts.update(nx.ancestors(G, t))
214   -
  214 + # FIXME filter by level done, only level < 50% are included
215 215 tl = list(nx.topological_sort(G.subgraph(ts)))
216 216  
217 217 # sort with unlocked first
... ...
setup.py
... ... @@ -20,8 +20,13 @@ setup(
20 20 include_package_data=True, # install files from MANIFEST.in
21 21 python_requires='>=3.7.*',
22 22 install_requires=[
23   - 'tornado', 'mistune', 'pyyaml', 'pygments', 'sqlalchemy', 'bcrypt',
24   - 'networkx'
  23 + 'tornado>=6.0',
  24 + 'mistune',
  25 + 'pyyaml>=5.1',
  26 + 'pygments',
  27 + 'sqlalchemy',
  28 + 'bcrypt>=3.1',
  29 + 'networkx>=2.4'
25 30 ],
26 31 entry_points={
27 32 'console_scripts': [
... ...