Commit f780fbf03bdd727a4fcadc33ad373a8646d0273a

Authored by Miguel Barão
1 parent 3db4db94
Exists in dev

refactor learnapp.py and remove dependency from tools

Showing 1 changed file with 63 additions and 68 deletions   Show diff stats
aprendizations/learnapp.py
@@ -11,19 +11,18 @@ import logging @@ -11,19 +11,18 @@ import logging
11 from random import random 11 from random import random
12 from os.path import join, exists 12 from os.path import join, exists
13 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict 13 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
  14 +import yaml
14 15
15 # third party libraries 16 # third party libraries
16 import bcrypt 17 import bcrypt
17 import networkx as nx 18 import networkx as nx
18 from sqlalchemy import create_engine, select, func 19 from sqlalchemy import create_engine, select, func
19 from sqlalchemy.orm import Session 20 from sqlalchemy.orm import Session
20 -from sqlalchemy.exc import NoResultFound  
21 21
22 # this project 22 # this project
23 from aprendizations.models import Student, Answer, Topic, StudentTopic 23 from aprendizations.models import Student, Answer, Topic, StudentTopic
24 -from aprendizations.questions import Question, QFactory, QDict, QuestionException 24 +from aprendizations.questions import Question, QFactory, QuestionException
25 from aprendizations.student import StudentState 25 from aprendizations.student import StudentState
26 -from aprendizations.tools import load_yaml  
27 26
28 27
29 # setup logger for this module 28 # setup logger for this module
@@ -63,71 +62,66 @@ class LearnApp(): @@ -63,71 +62,66 @@ class LearnApp():
63 } 62 }
64 ''' 63 '''
65 64
  65 + # ------------------------------------------------------------------------
66 def __init__(self, 66 def __init__(self,
67 courses: str, # filename with course configurations 67 courses: str, # filename with course configurations
68 prefix: str, # path to topics 68 prefix: str, # path to topics
69 dbase: str, # database filename 69 dbase: str, # database filename
70 check: bool = False) -> None: 70 check: bool = False) -> None:
71 71
  72 + self.online: Dict[str, Dict] = {} # online students
  73 + self.deps = nx.DiGraph(prefix=prefix) # dependencies for all courses
  74 +
72 self._db_setup(dbase) # setup database and check students 75 self._db_setup(dbase) # setup database and check students
73 - self.online: Dict[str, Dict] = {} # online students  
74 76
75 - try:  
76 - config: Dict[str, Any] = load_yaml(courses)  
77 - except Exception as exc:  
78 - msg = f'Failed to load yaml file "{courses}"'  
79 - logger.error(msg)  
80 - raise LearnException(msg) from exc 77 + with open(courses) as f:
  78 + config = yaml.safe_load(f)
81 79
82 - # --- topic dependencies are shared between all courses  
83 - self.deps = nx.DiGraph(prefix=prefix)  
84 logger.info('Populating topic graph:') 80 logger.info('Populating topic graph:')
85 -  
86 - # topics defined directly in the courses file, usually empty  
87 - base_topics = config.get('topics', {})  
88 - self._populate_graph(base_topics)  
89 - logger.info('%6d topics in %s', len(base_topics), courses)  
90 -  
91 - # load other course files with the topics the their deps  
92 - for course_file in config.get('topics_from', []):  
93 - course_conf = load_yaml(course_file) # course configuration  
94 - logger.info('%6d topics from %s',  
95 - len(course_conf["topics"]), course_file)  
96 - self._populate_graph(course_conf) 81 +
  82 + # --- import topics from files and populate dependency graph
  83 + for file in config['topics_from']:
  84 + with open(file) as f:
  85 + topics = yaml.safe_load(f)
  86 + logger.info('%6d topics from %s', len(topics["topics"]), file)
  87 + self._populate_graph(topics)
97 logger.info('Graph has %d topics', len(self.deps)) 88 logger.info('Graph has %d topics', len(self.deps))
98 89
99 - # --- courses dict 90 + # --- courses. check if goals exist and expand chapters to topics
  91 + logger.info('Courses:')
100 self.courses = config['courses'] 92 self.courses = config['courses']
101 - logger.info('Courses: %s', ', '.join(self.courses.keys()))  
102 for cid, course in self.courses.items(): 93 for cid, course in self.courses.items():
103 course.setdefault('title', cid) # course title undefined 94 course.setdefault('title', cid) # course title undefined
  95 + logger.info(' %s (%s)', course['title'], cid)
104 for goal in course['goals']: 96 for goal in course['goals']:
105 if goal not in self.deps.nodes(): 97 if goal not in self.deps.nodes():
106 - msg = f'Goal "{goal}" from course "{cid}" does not exist' 98 + msg = f'Goal "{goal}" does not exist!'
107 logger.error(msg) 99 logger.error(msg)
108 raise LearnException(msg) 100 raise LearnException(msg)
109 if self.deps.nodes[goal]['type'] == 'chapter': 101 if self.deps.nodes[goal]['type'] == 'chapter':
110 course['goals'] += [g for g in self.deps.predecessors(goal) 102 course['goals'] += [g for g in self.deps.predecessors(goal)
111 if g not in course['goals']] 103 if g not in course['goals']]
112 104
113 - # --- factory is a dict with question generators for all topics 105 + # --- factory is a dict with question generators (all topics)
114 self.factory: Dict[str, QFactory] = self._make_factory() 106 self.factory: Dict[str, QFactory] = self._make_factory()
115 107
116 - # if graph has topics that are not in the database, add them 108 + # --- if graph has topics that are not in the database, add them
117 self._add_missing_topics(self.deps.nodes()) 109 self._add_missing_topics(self.deps.nodes())
118 110
119 if check: 111 if check:
120 self._sanity_check_questions() 112 self._sanity_check_questions()
121 113
  114 + # ------------------------------------------------------------------------
122 def _sanity_check_questions(self) -> None: 115 def _sanity_check_questions(self) -> None:
123 ''' 116 '''
124 Unit tests for all questions 117 Unit tests for all questions
125 118
126 Generates all questions, give right and wrong answers and corrects. 119 Generates all questions, give right and wrong answers and corrects.
127 ''' 120 '''
  121 +
128 logger.info('Starting sanity checks (may take a while...)') 122 logger.info('Starting sanity checks (may take a while...)')
129 123
130 - errors: int = 0 124 + errors = 0
131 for qref, qfactory in self.factory.items(): 125 for qref, qfactory in self.factory.items():
132 logger.debug('checking %s...', qref) 126 logger.debug('checking %s...', qref)
133 try: 127 try:
@@ -146,14 +140,13 @@ class LearnApp(): @@ -146,14 +140,13 @@ class LearnApp():
146 errors += 1 140 errors += 1
147 continue # to next test 141 continue # to next test
148 elif question['type'] == 'textarea': 142 elif question['type'] == 'textarea':
149 - msg = f'- consider adding tests to {question["ref"]}'  
150 - logger.warning(msg) 143 + logger.warning('- missing tests in ', question["ref"])
151 144
152 if 'tests_wrong' in question: 145 if 'tests_wrong' in question:
153 for wrong_answer in question['tests_wrong']: 146 for wrong_answer in question['tests_wrong']:
154 question['answer'] = wrong_answer 147 question['answer'] = wrong_answer
155 question.correct() 148 question.correct()
156 - if question['grade'] >= 1.0: 149 + if question['grade'] >= 0.999:
157 logger.error('Failed wrong answer in "%s".', qref) 150 logger.error('Failed wrong answer in "%s".', qref)
158 errors += 1 151 errors += 1
159 continue # to next test 152 continue # to next test
@@ -163,6 +156,7 @@ class LearnApp(): @@ -163,6 +156,7 @@ class LearnApp():
163 raise LearnException('Sanity checks') 156 raise LearnException('Sanity checks')
164 logger.info(' 0 errors found.') 157 logger.info(' 0 errors found.')
165 158
  159 + # ------------------------------------------------------------------------
166 async def login(self, uid: str, password: str) -> bool: 160 async def login(self, uid: str, password: str) -> bool:
167 '''user login''' 161 '''user login'''
168 162
@@ -170,10 +164,9 @@ class LearnApp(): @@ -170,10 +164,9 @@ class LearnApp():
170 await asyncio.sleep(random()) 164 await asyncio.sleep(random())
171 165
172 query = select(Student).where(Student.id == uid) 166 query = select(Student).where(Student.id == uid)
173 - try:  
174 - with Session(self._engine, future=True) as session:  
175 - student = session.execute(query).scalar_one()  
176 - except NoResultFound: 167 + with Session(self._engine, future=True) as session:
  168 + student = session.execute(query).scalar_one_or_none()
  169 + if student is None:
177 logger.info('User "%s" does not exist', uid) 170 logger.info('User "%s" does not exist', uid)
178 return False 171 return False
179 172
@@ -207,7 +200,7 @@ class LearnApp(): @@ -207,7 +200,7 @@ class LearnApp():
207 'state': StudentState(uid=uid, state=state, 200 'state': StudentState(uid=uid, state=state,
208 courses=self.courses, deps=self.deps, 201 courses=self.courses, deps=self.deps,
209 factory=self.factory), 202 factory=self.factory),
210 - 'counter': counter + 1, # counts simultaneous logins 203 + 'counter': counter + 1, # count simultaneous logins
211 } 204 }
212 205
213 else: 206 else:
@@ -215,11 +208,13 @@ class LearnApp(): @@ -215,11 +208,13 @@ class LearnApp():
215 208
216 return pw_ok 209 return pw_ok
217 210
  211 + # ------------------------------------------------------------------------
218 def logout(self, uid: str) -> None: 212 def logout(self, uid: str) -> None:
219 '''User logout''' 213 '''User logout'''
220 del self.online[uid] 214 del self.online[uid]
221 logger.info('User "%s" logged out', uid) 215 logger.info('User "%s" logged out', uid)
222 216
  217 + # ------------------------------------------------------------------------
223 async def change_password(self, uid: str, password: str) -> bool: 218 async def change_password(self, uid: str, password: str) -> bool:
224 ''' 219 '''
225 Change user Password. 220 Change user Password.
@@ -234,15 +229,15 @@ class LearnApp(): @@ -234,15 +229,15 @@ class LearnApp():
234 password.encode('utf-8'), 229 password.encode('utf-8'),
235 bcrypt.gensalt()) 230 bcrypt.gensalt())
236 231
  232 + query = select(Student).where(Student.id == uid)
237 with Session(self._engine, future=True) as session: 233 with Session(self._engine, future=True) as session:
238 - query = select(Student).where(Student.id == uid)  
239 - user = session.execute(query).scalar_one()  
240 - user.password = hashed_pw 234 + session.execute(query).scalar_one().password = hashed_pw
241 session.commit() 235 session.commit()
242 236
243 logger.info('User "%s" changed password', uid) 237 logger.info('User "%s" changed password', uid)
244 return True 238 return True
245 239
  240 + # ------------------------------------------------------------------------
246 async def check_answer(self, uid: str, answer) -> Question: 241 async def check_answer(self, uid: str, answer) -> Question:
247 ''' 242 '''
248 Checks answer and update database. 243 Checks answer and update database.
@@ -252,7 +247,7 @@ class LearnApp(): @@ -252,7 +247,7 @@ class LearnApp():
252 await student.check_answer(answer) 247 await student.check_answer(answer)
253 248
254 topic_id = student.get_current_topic() 249 topic_id = student.get_current_topic()
255 - question: Question = student.get_current_question() 250 + question = student.get_current_question()
256 grade = question["grade"] 251 grade = question["grade"]
257 ref = question["ref"] 252 ref = question["ref"]
258 253
@@ -271,6 +266,7 @@ class LearnApp(): @@ -271,6 +266,7 @@ class LearnApp():
271 266
272 return question 267 return question
273 268
  269 + # ------------------------------------------------------------------------
274 async def get_question(self, uid: str) -> Optional[Question]: 270 async def get_question(self, uid: str) -> Optional[Question]:
275 ''' 271 '''
276 Get the question to show (current or new one) 272 Get the question to show (current or new one)
@@ -281,22 +277,20 @@ class LearnApp(): @@ -281,22 +277,20 @@ class LearnApp():
281 277
282 # save topic to database if finished 278 # save topic to database if finished
283 if student_state.topic_has_finished(): 279 if student_state.topic_has_finished():
284 - topic_id: str = student_state.get_previous_topic()  
285 - level: float = student_state.get_topic_level(topic_id)  
286 - date: str = str(student_state.get_topic_date(topic_id))  
287 - logger.info('User "%s" finished "%s" (level=%.2f)',  
288 - uid, topic_id, level) 280 + tid: str = student_state.get_previous_topic()
  281 + level: float = student_state.get_topic_level(tid)
  282 + date: str = str(student_state.get_topic_date(tid))
  283 + logger.info('"%s" finished "%s" (level=%.2f)', uid, tid, level)
289 284
290 query = select(StudentTopic) \ 285 query = select(StudentTopic) \
291 .where(StudentTopic.student_id == uid) \ 286 .where(StudentTopic.student_id == uid) \
292 - .where(StudentTopic.topic_id == topic_id) 287 + .where(StudentTopic.topic_id == tid)
293 with Session(self._engine, future=True) as session: 288 with Session(self._engine, future=True) as session:
294 student_topic = session.execute(query).scalar_one_or_none() 289 student_topic = session.execute(query).scalar_one_or_none()
295 -  
296 if student_topic is None: 290 if student_topic is None:
297 # insert new studenttopic into database 291 # insert new studenttopic into database
298 logger.debug('db insert studenttopic') 292 logger.debug('db insert studenttopic')
299 - query_topic = select(Topic).where(Topic.id == topic_id) 293 + query_topic = select(Topic).where(Topic.id == tid)
300 query_student = select(Student).where(Student.id == uid) 294 query_student = select(Student).where(Student.id == uid)
301 topic = session.execute(query_topic).scalar_one() 295 topic = session.execute(query_topic).scalar_one()
302 student = session.execute(query_student).scalar_one() 296 student = session.execute(query_student).scalar_one()
@@ -311,12 +305,12 @@ class LearnApp(): @@ -311,12 +305,12 @@ class LearnApp():
311 logger.debug('db update studenttopic to level %f', level) 305 logger.debug('db update studenttopic to level %f', level)
312 student_topic.level = level 306 student_topic.level = level
313 student_topic.date = date 307 student_topic.date = date
314 -  
315 session.add(student_topic) 308 session.add(student_topic)
316 session.commit() 309 session.commit()
317 310
318 return question 311 return question
319 312
  313 + # ------------------------------------------------------------------------
320 def start_course(self, uid: str, course_id: str) -> None: 314 def start_course(self, uid: str, course_id: str) -> None:
321 '''Start course''' 315 '''Start course'''
322 316
@@ -329,6 +323,7 @@ class LearnApp(): @@ -329,6 +323,7 @@ class LearnApp():
329 else: 323 else:
330 logger.info('User "%s" course "%s"', uid, course_id) 324 logger.info('User "%s" course "%s"', uid, course_id)
331 325
  326 + # ------------------------------------------------------------------------
332 async def start_topic(self, uid: str, topic: str) -> None: 327 async def start_topic(self, uid: str, topic: str) -> None:
333 '''Start new topic''' 328 '''Start new topic'''
334 329
@@ -342,9 +337,11 @@ class LearnApp(): @@ -342,9 +337,11 @@ class LearnApp():
342 else: 337 else:
343 logger.info('User "%s" started topic "%s"', uid, topic) 338 logger.info('User "%s" started topic "%s"', uid, topic)
344 339
  340 + # ------------------------------------------------------------------------
  341 + # ------------------------------------------------------------------------
345 def _add_missing_topics(self, topics: Iterable[str]) -> None: 342 def _add_missing_topics(self, topics: Iterable[str]) -> None:
346 ''' 343 '''
347 - Fill db table 'Topic' with topics from the graph, if new 344 + Fill table 'Topic' with topics from the graph, if new
348 ''' 345 '''
349 with Session(self._engine, future=True) as session: 346 with Session(self._engine, future=True) as session:
350 db_topics = session.execute(select(Topic.id)).scalars().all() 347 db_topics = session.execute(select(Topic.id)).scalars().all()
@@ -359,14 +356,11 @@ class LearnApp(): @@ -359,14 +356,11 @@ class LearnApp():
359 Setup and check database contents 356 Setup and check database contents
360 ''' 357 '''
361 358
362 - logger.info('Checking database "%s":', database) 359 + logger.info('Checking database "%s"', database)
363 if not exists(database): 360 if not exists(database):
364 - msg = 'Database does not exist.'  
365 - logger.error(msg)  
366 - raise LearnException(msg) 361 + raise LearnException('Database does not exist')
367 362
368 self._engine = create_engine(f'sqlite:///{database}', future=True) 363 self._engine = create_engine(f'sqlite:///{database}', future=True)
369 -  
370 try: 364 try:
371 query_students = select(func.count(Student.id)) 365 query_students = select(func.count(Student.id))
372 query_topics = select(func.count(Topic.id)) 366 query_topics = select(func.count(Topic.id))
@@ -375,14 +369,14 @@ class LearnApp(): @@ -375,14 +369,14 @@ class LearnApp():
375 count_students = session.execute(query_students).scalar() 369 count_students = session.execute(query_students).scalar()
376 count_topics = session.execute(query_topics).scalar() 370 count_topics = session.execute(query_topics).scalar()
377 count_answers = session.execute(query_answers).scalar() 371 count_answers = session.execute(query_answers).scalar()
378 - except Exception as exc:  
379 - logger.error('Database "%s" not usable!', database)  
380 - raise DatabaseUnusableError() from exc  
381 - else:  
382 - logger.info('%6d students', count_students)  
383 - logger.info('%6d topics', count_topics)  
384 - logger.info('%6d answers', count_answers)  
385 - 372 + except Exception:
  373 + logger.error('Database not usable!')
  374 + raise LearnException('Database not usable!')
  375 + logger.info('%6d students', count_students)
  376 + logger.info('%6d topics', count_topics)
  377 + logger.info('%6d answers', count_answers)
  378 +
  379 + # ------------------------------------------------------------------------
386 def _populate_graph(self, config: Dict[str, Any]) -> None: 380 def _populate_graph(self, config: Dict[str, Any]) -> None:
387 ''' 381 '''
388 Populates a digraph. 382 Populates a digraph.
@@ -391,7 +385,7 @@ class LearnApp(): @@ -391,7 +385,7 @@ class LearnApp():
391 g.nodes['my/topic']['name'] name of the topic 385 g.nodes['my/topic']['name'] name of the topic
392 g.nodes['my/topic']['questions'] list of question refs 386 g.nodes['my/topic']['questions'] list of question refs
393 387
394 - Edges are obtained from the deps defined in the YAML file for each topic. 388 + Edges are obtained from the deps defined in the YAML file
395 ''' 389 '''
396 390
397 defaults = { 391 defaults = {
@@ -451,7 +445,7 @@ class LearnApp(): @@ -451,7 +445,7 @@ class LearnApp():
451 topic = self.deps.nodes[tref] # get node 445 topic = self.deps.nodes[tref] # get node
452 # load questions as list of dicts 446 # load questions as list of dicts
453 try: 447 try:
454 - fullpath: str = join(topic['path'], topic['file']) 448 + fullpath = join(topic['path'], topic['file'])
455 except Exception as exc: 449 except Exception as exc:
456 msg = f'Invalid topic "{tref}". Check dependencies of: ' + \ 450 msg = f'Invalid topic "{tref}". Check dependencies of: ' + \
457 ', '.join(self.deps.successors(tref)) 451 ', '.join(self.deps.successors(tref))
@@ -459,7 +453,8 @@ class LearnApp(): @@ -459,7 +453,8 @@ class LearnApp():
459 raise LearnException(msg) from exc 453 raise LearnException(msg) from exc
460 logger.debug(' Loading %s', fullpath) 454 logger.debug(' Loading %s', fullpath)
461 try: 455 try:
462 - questions: List[QDict] = load_yaml(fullpath) 456 + with open(fullpath, 'r') as f:
  457 + questions = yaml.safe_load(f)
463 except Exception as exc: 458 except Exception as exc:
464 if topic['type'] == 'chapter': 459 if topic['type'] == 'chapter':
465 return factory # chapters may have no "questions" 460 return factory # chapters may have no "questions"