diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index 89a1187..1765916 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -11,19 +11,18 @@ import logging from random import random from os.path import join, exists from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict +import yaml # third party libraries import bcrypt import networkx as nx from sqlalchemy import create_engine, select, func from sqlalchemy.orm import Session -from sqlalchemy.exc import NoResultFound # this project from aprendizations.models import Student, Answer, Topic, StudentTopic -from aprendizations.questions import Question, QFactory, QDict, QuestionException +from aprendizations.questions import Question, QFactory, QuestionException from aprendizations.student import StudentState -from aprendizations.tools import load_yaml # setup logger for this module @@ -63,71 +62,66 @@ class LearnApp(): } ''' + # ------------------------------------------------------------------------ def __init__(self, courses: str, # filename with course configurations prefix: str, # path to topics dbase: str, # database filename check: bool = False) -> None: + self.online: Dict[str, Dict] = {} # online students + self.deps = nx.DiGraph(prefix=prefix) # dependencies for all courses + self._db_setup(dbase) # setup database and check students - self.online: Dict[str, Dict] = {} # online students - try: - config: Dict[str, Any] = load_yaml(courses) - except Exception as exc: - msg = f'Failed to load yaml file "{courses}"' - logger.error(msg) - raise LearnException(msg) from exc + with open(courses) as f: + config = yaml.safe_load(f) - # --- topic dependencies are shared between all courses - self.deps = nx.DiGraph(prefix=prefix) logger.info('Populating topic graph:') - - # topics defined directly in the courses file, usually empty - base_topics = config.get('topics', {}) - self._populate_graph(base_topics) - logger.info('%6d topics in %s', len(base_topics), courses) - - # load other course files with the topics the their deps - for course_file in config.get('topics_from', []): - course_conf = load_yaml(course_file) # course configuration - logger.info('%6d topics from %s', - len(course_conf["topics"]), course_file) - self._populate_graph(course_conf) + + # --- import topics from files and populate dependency graph + for file in config['topics_from']: + with open(file) as f: + topics = yaml.safe_load(f) + logger.info('%6d topics from %s', len(topics["topics"]), file) + self._populate_graph(topics) logger.info('Graph has %d topics', len(self.deps)) - # --- courses dict + # --- courses. check if goals exist and expand chapters to topics + logger.info('Courses:') self.courses = config['courses'] - logger.info('Courses: %s', ', '.join(self.courses.keys())) for cid, course in self.courses.items(): course.setdefault('title', cid) # course title undefined + logger.info(' %s (%s)', course['title'], cid) for goal in course['goals']: if goal not in self.deps.nodes(): - msg = f'Goal "{goal}" from course "{cid}" does not exist' + msg = f'Goal "{goal}" does not exist!' logger.error(msg) raise LearnException(msg) if self.deps.nodes[goal]['type'] == 'chapter': course['goals'] += [g for g in self.deps.predecessors(goal) if g not in course['goals']] - # --- factory is a dict with question generators for all topics + # --- factory is a dict with question generators (all topics) self.factory: Dict[str, QFactory] = self._make_factory() - # if graph has topics that are not in the database, add them + # --- if graph has topics that are not in the database, add them self._add_missing_topics(self.deps.nodes()) if check: self._sanity_check_questions() + # ------------------------------------------------------------------------ def _sanity_check_questions(self) -> None: ''' Unit tests for all questions Generates all questions, give right and wrong answers and corrects. ''' + logger.info('Starting sanity checks (may take a while...)') - errors: int = 0 + errors = 0 for qref, qfactory in self.factory.items(): logger.debug('checking %s...', qref) try: @@ -146,14 +140,13 @@ class LearnApp(): errors += 1 continue # to next test elif question['type'] == 'textarea': - msg = f'- consider adding tests to {question["ref"]}' - logger.warning(msg) + logger.warning('- missing tests in ', question["ref"]) if 'tests_wrong' in question: for wrong_answer in question['tests_wrong']: question['answer'] = wrong_answer question.correct() - if question['grade'] >= 1.0: + if question['grade'] >= 0.999: logger.error('Failed wrong answer in "%s".', qref) errors += 1 continue # to next test @@ -163,6 +156,7 @@ class LearnApp(): raise LearnException('Sanity checks') logger.info(' 0 errors found.') + # ------------------------------------------------------------------------ async def login(self, uid: str, password: str) -> bool: '''user login''' @@ -170,10 +164,9 @@ class LearnApp(): await asyncio.sleep(random()) query = select(Student).where(Student.id == uid) - try: - with Session(self._engine, future=True) as session: - student = session.execute(query).scalar_one() - except NoResultFound: + with Session(self._engine, future=True) as session: + student = session.execute(query).scalar_one_or_none() + if student is None: logger.info('User "%s" does not exist', uid) return False @@ -207,7 +200,7 @@ class LearnApp(): 'state': StudentState(uid=uid, state=state, courses=self.courses, deps=self.deps, factory=self.factory), - 'counter': counter + 1, # counts simultaneous logins + 'counter': counter + 1, # count simultaneous logins } else: @@ -215,11 +208,13 @@ class LearnApp(): return pw_ok + # ------------------------------------------------------------------------ def logout(self, uid: str) -> None: '''User logout''' del self.online[uid] logger.info('User "%s" logged out', uid) + # ------------------------------------------------------------------------ async def change_password(self, uid: str, password: str) -> bool: ''' Change user Password. @@ -234,15 +229,15 @@ class LearnApp(): password.encode('utf-8'), bcrypt.gensalt()) + query = select(Student).where(Student.id == uid) with Session(self._engine, future=True) as session: - query = select(Student).where(Student.id == uid) - user = session.execute(query).scalar_one() - user.password = hashed_pw + session.execute(query).scalar_one().password = hashed_pw session.commit() logger.info('User "%s" changed password', uid) return True + # ------------------------------------------------------------------------ async def check_answer(self, uid: str, answer) -> Question: ''' Checks answer and update database. @@ -252,7 +247,7 @@ class LearnApp(): await student.check_answer(answer) topic_id = student.get_current_topic() - question: Question = student.get_current_question() + question = student.get_current_question() grade = question["grade"] ref = question["ref"] @@ -271,6 +266,7 @@ class LearnApp(): return question + # ------------------------------------------------------------------------ async def get_question(self, uid: str) -> Optional[Question]: ''' Get the question to show (current or new one) @@ -281,22 +277,20 @@ class LearnApp(): # save topic to database if finished if student_state.topic_has_finished(): - topic_id: str = student_state.get_previous_topic() - level: float = student_state.get_topic_level(topic_id) - date: str = str(student_state.get_topic_date(topic_id)) - logger.info('User "%s" finished "%s" (level=%.2f)', - uid, topic_id, level) + tid: str = student_state.get_previous_topic() + level: float = student_state.get_topic_level(tid) + date: str = str(student_state.get_topic_date(tid)) + logger.info('"%s" finished "%s" (level=%.2f)', uid, tid, level) query = select(StudentTopic) \ .where(StudentTopic.student_id == uid) \ - .where(StudentTopic.topic_id == topic_id) + .where(StudentTopic.topic_id == tid) with Session(self._engine, future=True) as session: student_topic = session.execute(query).scalar_one_or_none() - if student_topic is None: # insert new studenttopic into database logger.debug('db insert studenttopic') - query_topic = select(Topic).where(Topic.id == topic_id) + query_topic = select(Topic).where(Topic.id == tid) query_student = select(Student).where(Student.id == uid) topic = session.execute(query_topic).scalar_one() student = session.execute(query_student).scalar_one() @@ -311,12 +305,12 @@ class LearnApp(): logger.debug('db update studenttopic to level %f', level) student_topic.level = level student_topic.date = date - session.add(student_topic) session.commit() return question + # ------------------------------------------------------------------------ def start_course(self, uid: str, course_id: str) -> None: '''Start course''' @@ -329,6 +323,7 @@ class LearnApp(): else: logger.info('User "%s" course "%s"', uid, course_id) + # ------------------------------------------------------------------------ async def start_topic(self, uid: str, topic: str) -> None: '''Start new topic''' @@ -342,9 +337,11 @@ class LearnApp(): else: logger.info('User "%s" started topic "%s"', uid, topic) + # ------------------------------------------------------------------------ + # ------------------------------------------------------------------------ def _add_missing_topics(self, topics: Iterable[str]) -> None: ''' - Fill db table 'Topic' with topics from the graph, if new + Fill table 'Topic' with topics from the graph, if new ''' with Session(self._engine, future=True) as session: db_topics = session.execute(select(Topic.id)).scalars().all() @@ -359,14 +356,11 @@ class LearnApp(): Setup and check database contents ''' - logger.info('Checking database "%s":', database) + logger.info('Checking database "%s"', database) if not exists(database): - msg = 'Database does not exist.' - logger.error(msg) - raise LearnException(msg) + raise LearnException('Database does not exist') self._engine = create_engine(f'sqlite:///{database}', future=True) - try: query_students = select(func.count(Student.id)) query_topics = select(func.count(Topic.id)) @@ -375,14 +369,14 @@ class LearnApp(): count_students = session.execute(query_students).scalar() count_topics = session.execute(query_topics).scalar() count_answers = session.execute(query_answers).scalar() - except Exception as exc: - logger.error('Database "%s" not usable!', database) - raise DatabaseUnusableError() from exc - else: - logger.info('%6d students', count_students) - logger.info('%6d topics', count_topics) - logger.info('%6d answers', count_answers) - + except Exception: + logger.error('Database not usable!') + raise LearnException('Database not usable!') + logger.info('%6d students', count_students) + logger.info('%6d topics', count_topics) + logger.info('%6d answers', count_answers) + + # ------------------------------------------------------------------------ def _populate_graph(self, config: Dict[str, Any]) -> None: ''' Populates a digraph. @@ -391,7 +385,7 @@ class LearnApp(): g.nodes['my/topic']['name'] name of the topic g.nodes['my/topic']['questions'] list of question refs - Edges are obtained from the deps defined in the YAML file for each topic. + Edges are obtained from the deps defined in the YAML file ''' defaults = { @@ -451,7 +445,7 @@ class LearnApp(): topic = self.deps.nodes[tref] # get node # load questions as list of dicts try: - fullpath: str = join(topic['path'], topic['file']) + fullpath = join(topic['path'], topic['file']) except Exception as exc: msg = f'Invalid topic "{tref}". Check dependencies of: ' + \ ', '.join(self.deps.successors(tref)) @@ -459,7 +453,8 @@ class LearnApp(): raise LearnException(msg) from exc logger.debug(' Loading %s', fullpath) try: - questions: List[QDict] = load_yaml(fullpath) + with open(fullpath, 'r') as f: + questions = yaml.safe_load(f) except Exception as exc: if topic['type'] == 'chapter': return factory # chapters may have no "questions" -- libgit2 0.21.2