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 11 from random import random
12 12 from os.path import join, exists
13 13 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
  14 +import yaml
14 15  
15 16 # third party libraries
16 17 import bcrypt
17 18 import networkx as nx
18 19 from sqlalchemy import create_engine, select, func
19 20 from sqlalchemy.orm import Session
20   -from sqlalchemy.exc import NoResultFound
21 21  
22 22 # this project
23 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 25 from aprendizations.student import StudentState
26   -from aprendizations.tools import load_yaml
27 26  
28 27  
29 28 # setup logger for this module
... ... @@ -63,71 +62,66 @@ class LearnApp():
63 62 }
64 63 '''
65 64  
  65 + # ------------------------------------------------------------------------
66 66 def __init__(self,
67 67 courses: str, # filename with course configurations
68 68 prefix: str, # path to topics
69 69 dbase: str, # database filename
70 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 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 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 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 92 self.courses = config['courses']
101   - logger.info('Courses: %s', ', '.join(self.courses.keys()))
102 93 for cid, course in self.courses.items():
103 94 course.setdefault('title', cid) # course title undefined
  95 + logger.info(' %s (%s)', course['title'], cid)
104 96 for goal in course['goals']:
105 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 99 logger.error(msg)
108 100 raise LearnException(msg)
109 101 if self.deps.nodes[goal]['type'] == 'chapter':
110 102 course['goals'] += [g for g in self.deps.predecessors(goal)
111 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 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 109 self._add_missing_topics(self.deps.nodes())
118 110  
119 111 if check:
120 112 self._sanity_check_questions()
121 113  
  114 + # ------------------------------------------------------------------------
122 115 def _sanity_check_questions(self) -> None:
123 116 '''
124 117 Unit tests for all questions
125 118  
126 119 Generates all questions, give right and wrong answers and corrects.
127 120 '''
  121 +
128 122 logger.info('Starting sanity checks (may take a while...)')
129 123  
130   - errors: int = 0
  124 + errors = 0
131 125 for qref, qfactory in self.factory.items():
132 126 logger.debug('checking %s...', qref)
133 127 try:
... ... @@ -146,14 +140,13 @@ class LearnApp():
146 140 errors += 1
147 141 continue # to next test
148 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 145 if 'tests_wrong' in question:
153 146 for wrong_answer in question['tests_wrong']:
154 147 question['answer'] = wrong_answer
155 148 question.correct()
156   - if question['grade'] >= 1.0:
  149 + if question['grade'] >= 0.999:
157 150 logger.error('Failed wrong answer in "%s".', qref)
158 151 errors += 1
159 152 continue # to next test
... ... @@ -163,6 +156,7 @@ class LearnApp():
163 156 raise LearnException('Sanity checks')
164 157 logger.info(' 0 errors found.')
165 158  
  159 + # ------------------------------------------------------------------------
166 160 async def login(self, uid: str, password: str) -> bool:
167 161 '''user login'''
168 162  
... ... @@ -170,10 +164,9 @@ class LearnApp():
170 164 await asyncio.sleep(random())
171 165  
172 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 170 logger.info('User "%s" does not exist', uid)
178 171 return False
179 172  
... ... @@ -207,7 +200,7 @@ class LearnApp():
207 200 'state': StudentState(uid=uid, state=state,
208 201 courses=self.courses, deps=self.deps,
209 202 factory=self.factory),
210   - 'counter': counter + 1, # counts simultaneous logins
  203 + 'counter': counter + 1, # count simultaneous logins
211 204 }
212 205  
213 206 else:
... ... @@ -215,11 +208,13 @@ class LearnApp():
215 208  
216 209 return pw_ok
217 210  
  211 + # ------------------------------------------------------------------------
218 212 def logout(self, uid: str) -> None:
219 213 '''User logout'''
220 214 del self.online[uid]
221 215 logger.info('User "%s" logged out', uid)
222 216  
  217 + # ------------------------------------------------------------------------
223 218 async def change_password(self, uid: str, password: str) -> bool:
224 219 '''
225 220 Change user Password.
... ... @@ -234,15 +229,15 @@ class LearnApp():
234 229 password.encode('utf-8'),
235 230 bcrypt.gensalt())
236 231  
  232 + query = select(Student).where(Student.id == uid)
237 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 235 session.commit()
242 236  
243 237 logger.info('User "%s" changed password', uid)
244 238 return True
245 239  
  240 + # ------------------------------------------------------------------------
246 241 async def check_answer(self, uid: str, answer) -> Question:
247 242 '''
248 243 Checks answer and update database.
... ... @@ -252,7 +247,7 @@ class LearnApp():
252 247 await student.check_answer(answer)
253 248  
254 249 topic_id = student.get_current_topic()
255   - question: Question = student.get_current_question()
  250 + question = student.get_current_question()
256 251 grade = question["grade"]
257 252 ref = question["ref"]
258 253  
... ... @@ -271,6 +266,7 @@ class LearnApp():
271 266  
272 267 return question
273 268  
  269 + # ------------------------------------------------------------------------
274 270 async def get_question(self, uid: str) -> Optional[Question]:
275 271 '''
276 272 Get the question to show (current or new one)
... ... @@ -281,22 +277,20 @@ class LearnApp():
281 277  
282 278 # save topic to database if finished
283 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 285 query = select(StudentTopic) \
291 286 .where(StudentTopic.student_id == uid) \
292   - .where(StudentTopic.topic_id == topic_id)
  287 + .where(StudentTopic.topic_id == tid)
293 288 with Session(self._engine, future=True) as session:
294 289 student_topic = session.execute(query).scalar_one_or_none()
295   -
296 290 if student_topic is None:
297 291 # insert new studenttopic into database
298 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 294 query_student = select(Student).where(Student.id == uid)
301 295 topic = session.execute(query_topic).scalar_one()
302 296 student = session.execute(query_student).scalar_one()
... ... @@ -311,12 +305,12 @@ class LearnApp():
311 305 logger.debug('db update studenttopic to level %f', level)
312 306 student_topic.level = level
313 307 student_topic.date = date
314   -
315 308 session.add(student_topic)
316 309 session.commit()
317 310  
318 311 return question
319 312  
  313 + # ------------------------------------------------------------------------
320 314 def start_course(self, uid: str, course_id: str) -> None:
321 315 '''Start course'''
322 316  
... ... @@ -329,6 +323,7 @@ class LearnApp():
329 323 else:
330 324 logger.info('User "%s" course "%s"', uid, course_id)
331 325  
  326 + # ------------------------------------------------------------------------
332 327 async def start_topic(self, uid: str, topic: str) -> None:
333 328 '''Start new topic'''
334 329  
... ... @@ -342,9 +337,11 @@ class LearnApp():
342 337 else:
343 338 logger.info('User "%s" started topic "%s"', uid, topic)
344 339  
  340 + # ------------------------------------------------------------------------
  341 + # ------------------------------------------------------------------------
345 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 346 with Session(self._engine, future=True) as session:
350 347 db_topics = session.execute(select(Topic.id)).scalars().all()
... ... @@ -359,14 +356,11 @@ class LearnApp():
359 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 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 363 self._engine = create_engine(f'sqlite:///{database}', future=True)
369   -
370 364 try:
371 365 query_students = select(func.count(Student.id))
372 366 query_topics = select(func.count(Topic.id))
... ... @@ -375,14 +369,14 @@ class LearnApp():
375 369 count_students = session.execute(query_students).scalar()
376 370 count_topics = session.execute(query_topics).scalar()
377 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 380 def _populate_graph(self, config: Dict[str, Any]) -> None:
387 381 '''
388 382 Populates a digraph.
... ... @@ -391,7 +385,7 @@ class LearnApp():
391 385 g.nodes['my/topic']['name'] name of the topic
392 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 391 defaults = {
... ... @@ -451,7 +445,7 @@ class LearnApp():
451 445 topic = self.deps.nodes[tref] # get node
452 446 # load questions as list of dicts
453 447 try:
454   - fullpath: str = join(topic['path'], topic['file'])
  448 + fullpath = join(topic['path'], topic['file'])
455 449 except Exception as exc:
456 450 msg = f'Invalid topic "{tref}". Check dependencies of: ' + \
457 451 ', '.join(self.deps.successors(tref))
... ... @@ -459,7 +453,8 @@ class LearnApp():
459 453 raise LearnException(msg) from exc
460 454 logger.debug(' Loading %s', fullpath)
461 455 try:
462   - questions: List[QDict] = load_yaml(fullpath)
  456 + with open(fullpath, 'r') as f:
  457 + questions = yaml.safe_load(f)
463 458 except Exception as exc:
464 459 if topic['type'] == 'chapter':
465 460 return factory # chapters may have no "questions"
... ...