Commit f780fbf03bdd727a4fcadc33ad373a8646d0273a
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" |