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