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 | 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" | ... | ... |