diff --git a/BUGS.md b/BUGS.md index 6d08bb4..14c7450 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,6 +1,10 @@ # BUGS +- internal server error 500... experimentar cenario: aluno tem login efectuado, prof muda pw e faz login/logout. aluno obtem erro 500. +- chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos pensam que já terminaram e não conseguem progredir por causa das dependencias. +- if topic deps on invalid ref terminates server with "Unknown error". +- warning nos topics que não são usados em nenhum curso - nao esta a seguir o max_tries definido no ficheiro de dependencias. - devia mostrar timeout para o aluno saber a razao. - permitir configuracao para escolher entre static files locais ou remotos diff --git a/README.md b/README.md index 9fffa75..bb4c947 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD sudo apt install certbot # Ubuntu ``` -To generate or renew the certificates, ports 80 and 443 have to be accessible. The firewall and webserver have to be stopped. +To generate or renew the certificates, ports 80 and 443 have to be accessible. **The firewall and webserver have to be stopped**. ```sh sudo certbot certonly --standalone -d www.example.com # first time @@ -151,6 +151,7 @@ sudo certbot renew # renew Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `~/.local/share/certs` and change permissions to be readable: ```sh +cd ~/.local/share/certs sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem . sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem . chmod 400 cert.pem privkey.pem diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index b16b1f1..67d3a2e 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -1,3 +1,7 @@ +''' +Learn application. +This is the main controller of the application. +''' # python standard library import asyncio @@ -6,7 +10,7 @@ from contextlib import contextmanager # `with` statement in db sessions from datetime import datetime import logging from random import random -from os import path +from os.path import join, exists from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict # third party libraries @@ -15,10 +19,10 @@ import networkx as nx import sqlalchemy as sa # this project -from .models import Student, Answer, Topic, StudentTopic -from .questions import Question, QFactory, QDict, QuestionException -from .student import StudentState -from .tools import load_yaml +from aprendizations.models import Student, Answer, Topic, StudentTopic +from aprendizations.questions import Question, QFactory, QDict, QuestionException +from aprendizations.student import StudentState +from aprendizations.tools import load_yaml # setup logger for this module @@ -27,33 +31,37 @@ logger = logging.getLogger(__name__) # ============================================================================ class LearnException(Exception): - pass + '''Exceptions raised from the LearnApp class''' class DatabaseUnusableError(LearnException): - pass + '''Exception raised if the database fails in the initialization''' # ============================================================================ -# LearnApp - application logic -# -# self.deps - networkx topic dependencies -# self.courses - dict {course_id: {'title': ..., -# 'description': ..., -# 'goals': ...,}, ...} -# self.factory = dict {qref: QFactory()} -# self.online - dict {student_id: {'number': ..., -# 'name': ..., -# 'state': StudentState(), -# 'counter': ...}, ...} -# ============================================================================ -class LearnApp(object): - # ------------------------------------------------------------------------ - # helper to manage db sessions using the `with` statement, for example - # with self.db_session() as s: s.query(...) +class LearnApp(): + ''' + LearnApp - application logic + + self.deps - networkx topic dependencies + self.courses - dict {course_id: {'title': ..., + 'description': ..., + 'goals': ...,}, ...} + self.factory = dict {qref: QFactory()} + self.online - dict {student_id: {'number': ..., + 'name': ..., + 'state': StudentState(), + 'counter': ...}, ...} + ''' + + # ------------------------------------------------------------------------ @contextmanager - def db_session(self, **kw): + def _db_session(self, **kw): + ''' + helper to manage db sessions using the `with` statement, for example + with self._db_session() as s: s.query(...) + ''' session = self.Session(**kw) try: yield session @@ -66,15 +74,13 @@ class LearnApp(object): session.close() # ------------------------------------------------------------------------ - # init - # ------------------------------------------------------------------------ def __init__(self, courses: str, # filename with course configurations prefix: str, # path to topics db: str, # database filename check: bool = False) -> None: - self.db_setup(db) # setup database and check students + self._db_setup(db) # setup database and check students self.online: Dict[str, Dict] = dict() # online students try: @@ -88,123 +94,130 @@ class LearnApp(object): self.deps = nx.DiGraph(prefix=prefix) logger.info('Populating topic graph:') - t = config.get('topics', {}) # topics defined directly in courses file - self.populate_graph(t) - logger.info(f'{len(t):>6} topics in {courses}') - for f in config.get('topics_from', []): - c = load_yaml(f) # course configuration + # 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 # FIXME set defaults?? - logger.info(f'{len(c["topics"]):>6} topics imported from {f}') - self.populate_graph(c) - logger.info(f'Graph has {len(self.deps)} topics') + logger.info('%6d topics imported from %s', + len(course_conf["topics"]), course_file) + self._populate_graph(course_conf) + logger.info('Graph has %d topics', len(self.deps)) # --- courses dict self.courses = config['courses'] - logger.info(f'Courses: {", ".join(self.courses.keys())}') - for c, d in self.courses.items(): - d.setdefault('title', '') # course title undefined - for goal in d['goals']: + logger.info('Courses: %s', ', '.join(self.courses.keys())) + for cid, course in self.courses.items(): + course.setdefault('title', '') # course title undefined + for goal in course['goals']: if goal not in self.deps.nodes(): - msg = f'Goal "{goal}" from course "{c}" does not exist' + msg = f'Goal "{goal}" from course "{cid}" does not exist' logger.error(msg) raise LearnException(msg) - elif self.deps.nodes[goal]['type'] == 'chapter': - d['goals'] += [g for g in self.deps.predecessors(goal) - if g not in d['goals']] + 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 - self.factory: Dict[str, QFactory] = self.make_factory() + self.factory: Dict[str, QFactory] = self._make_factory() # if graph has topics that are not in the database, add them - self.add_missing_topics(self.deps.nodes()) + self._add_missing_topics(self.deps.nodes()) if check: - self.sanity_check_questions() + self._sanity_check_questions() # ------------------------------------------------------------------------ - def sanity_check_questions(self) -> None: + def _sanity_check_questions(self) -> None: + ''' + Unity 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 for qref in self.factory: - logger.debug(f'checking {qref}...') + logger.debug('checking %s...', qref) try: - q = self.factory[qref].generate() - except QuestionException as e: - logger.error(e) + question = self.factory[qref].generate() + except QuestionException as exc: + logger.error(exc) errors += 1 continue # to next question - if 'tests_right' in q: - for t in q['tests_right']: - q['answer'] = t - q.correct() - if q['grade'] < 1.0: - logger.error(f'Failed right answer in "{qref}".') + if 'tests_right' in question: + for right_answer in question['tests_right']: + question['answer'] = right_answer + question.correct() + if question['grade'] < 1.0: + logger.error('Failed right answer in "%s".', qref) errors += 1 continue # to next test - elif q['type'] == 'textarea': - msg = f' consider adding tests to {q["ref"]}' + elif question['type'] == 'textarea': + msg = f'- consider adding tests to {question["ref"]}' logger.warning(msg) - if 'tests_wrong' in q: - for t in q['tests_wrong']: - q['answer'] = t - q.correct() - if q['grade'] >= 1.0: - logger.error(f'Failed wrong answer in "{qref}".') + if 'tests_wrong' in question: + for wrong_answer in question['tests_wrong']: + question['answer'] = wrong_answer + question.correct() + if question['grade'] >= 1.0: + logger.error('Failed wrong answer in "%s".', qref) errors += 1 continue # to next test if errors > 0: - logger.error(f'{errors:>6} error(s) found.') + logger.error('%6d error(s) found.', errors) # {errors:>6} raise LearnException('Sanity checks') - else: - logger.info(' 0 errors found.') + logger.info(' 0 errors found.') # ------------------------------------------------------------------------ - # login - # ------------------------------------------------------------------------ - async def login(self, uid: str, pw: str) -> bool: + async def login(self, uid: str, password: str) -> bool: + '''user login''' - with self.db_session() as s: - found = s.query(Student.name, Student.password) \ - .filter_by(id=uid) \ - .one_or_none() + with self._db_session() as sess: + found = sess.query(Student.name, Student.password) \ + .filter_by(id=uid) \ + .one_or_none() # wait random time to minimize timing attacks await asyncio.sleep(random()) loop = asyncio.get_running_loop() if found is None: - logger.info(f'User "{uid}" does not exist') + logger.info('User "%s" does not exist', uid) await loop.run_in_executor(None, bcrypt.hashpw, b'', bcrypt.gensalt()) # just spend time return False - else: - name, hashed_pw = found - pw_ok: bool = await loop.run_in_executor(None, - bcrypt.checkpw, - pw.encode('utf-8'), - hashed_pw) + name, hashed_pw = found + pw_ok: bool = await loop.run_in_executor(None, + bcrypt.checkpw, + password.encode('utf-8'), + hashed_pw) if pw_ok: if uid in self.online: - logger.warning(f'User "{uid}" already logged in') + logger.warning('User "%s" already logged in', uid) counter = self.online[uid]['counter'] else: - logger.info(f'User "{uid}" logged in') + logger.info('User "%s" logged in', uid) counter = 0 # get topics of this student and set its current state - with self.db_session() as s: - tt = s.query(StudentTopic).filter_by(student_id=uid) + with self._db_session() as sess: + student_topics = sess.query(StudentTopic) \ + .filter_by(student_id=uid) state = {t.topic_id: { 'level': t.level, 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f") - } for t in tt} + } for t in student_topics} self.online[uid] = { 'number': uid, @@ -216,179 +229,192 @@ class LearnApp(object): } else: - logger.info(f'User "{uid}" wrong password') + logger.info('User "%s" wrong password', uid) return pw_ok # ------------------------------------------------------------------------ - # logout - # ------------------------------------------------------------------------ def logout(self, uid: str) -> None: + '''User logout''' del self.online[uid] - logger.info(f'User "{uid}" logged out') + logger.info('User "%s" logged out', uid) # ------------------------------------------------------------------------ - # change_password. returns True if password is successfully changed. - # ------------------------------------------------------------------------ - async def change_password(self, uid: str, pw: str) -> bool: - if not pw: + async def change_password(self, uid: str, password: str) -> bool: + ''' + Change user Password. + Returns True if password is successfully changed + ''' + if not password: return False loop = asyncio.get_running_loop() - pw = await loop.run_in_executor(None, bcrypt.hashpw, - pw.encode('utf-8'), bcrypt.gensalt()) + password = await loop.run_in_executor(None, + bcrypt.hashpw, + password.encode('utf-8'), + bcrypt.gensalt()) - with self.db_session() as s: - u = s.query(Student).get(uid) - u.password = pw + with self._db_session() as sess: + user = sess.query(Student).get(uid) + user.password = password - logger.info(f'User "{uid}" changed password') + logger.info('User "%s" changed password', uid) return True # ------------------------------------------------------------------------ - # Checks answer and update database. Returns corrected question. - # ------------------------------------------------------------------------ async def check_answer(self, uid: str, answer) -> Question: + ''' + Checks answer and update database. + Returns corrected question. + ''' student = self.online[uid]['state'] await student.check_answer(answer) - q: Question = student.get_current_question() - logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') + topic_id = student.get_current_topic() + question: Question = student.get_current_question() + grade = question["grade"] + ref = question["ref"] + + logger.info('User "%s" got %.2f in "%s"', uid, grade, ref) # always save grade of answered question - with self.db_session() as s: - s.add(Answer( - ref=q['ref'], - grade=q['grade'], - starttime=str(q['start_time']), - finishtime=str(q['finish_time']), - student_id=uid, - topic_id=student.get_current_topic())) + with self._db_session() as sess: + sess.add(Answer(ref=ref, + grade=grade, + starttime=str(question['start_time']), + finishtime=str(question['finish_time']), + student_id=uid, + topic_id=topic_id)) - return q + return question # ------------------------------------------------------------------------ - # get the question to show (current or new one) - # if no more questions, save/update level in database - # ------------------------------------------------------------------------ async def get_question(self, uid: str) -> Optional[Question]: + ''' + Get the question to show (current or new one) + If no more questions, save/update level in database + ''' student = self.online[uid]['state'] - q: Optional[Question] = await student.get_question() + question: Optional[Question] = await student.get_question() # save topic to database if finished if student.topic_has_finished(): topic: str = student.get_previous_topic() level: float = student.get_topic_level(topic) date: str = str(student.get_topic_date(topic)) - logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})') + logger.info('User "%s" finished "%s" (level=%.2f)', + uid, topic, level) + + with self._db_session() as sess: + student_topic = sess.query(StudentTopic) \ + .filter_by(student_id=uid, topic_id=topic)\ + .one_or_none() - with self.db_session() as s: - a = s.query(StudentTopic) \ - .filter_by(student_id=uid, topic_id=topic) \ - .one_or_none() - if a is None: + if student_topic is None: # insert new studenttopic into database logger.debug('db insert studenttopic') - t = s.query(Topic).get(topic) - u = s.query(Student).get(uid) + tid = sess.query(Topic).get(topic) + uid = sess.query(Student).get(uid) # association object - a = StudentTopic(level=level, date=date, topic=t, - student=u) - u.topics.append(a) + student_topic = StudentTopic(level=level, date=date, + topic=tid, student=uid) + uid.topics.append(student_topic) else: # update studenttopic in database - logger.debug(f'db update studenttopic to level {level}') - a.level = level - a.date = date + logger.debug('db update studenttopic to level %f', level) + student_topic.level = level + student_topic.date = date - s.add(a) + sess.add(student_topic) - return q + return question # ------------------------------------------------------------------------ - # Start course - # ------------------------------------------------------------------------ def start_course(self, uid: str, course_id: str) -> None: + '''Start course''' + student = self.online[uid]['state'] try: student.start_course(course_id) except Exception: - logger.warning(f'"{uid}" could not start course "{course_id}"') - raise + logger.warning('"%s" could not start course "%s"', uid, course_id) + raise LearnException() else: - logger.info(f'User "{uid}" started course "{course_id}"') + logger.info('User "%s" started course "%s"', uid, course_id) # ------------------------------------------------------------------------ - # Start new topic + # # ------------------------------------------------------------------------ async def start_topic(self, uid: str, topic: str) -> None: + '''Start new topic''' + student = self.online[uid]['state'] - if uid == '0': - logger.warning(f'Reloading "{topic}"') # FIXME should be an option - self.factory.update(self.factory_for(topic)) + # if uid == '0': + # logger.warning('Reloading "%s"', topic) # FIXME should be an option + # self.factory.update(self._factory_for(topic)) try: await student.start_topic(topic) - except Exception as e: - logger.warning(f'User "{uid}" could not start "{topic}": {e}') + except Exception as exc: + logger.warning('User "%s" could not start "%s": %s', + uid, topic, str(exc)) else: - logger.info(f'User "{uid}" started topic "{topic}"') + logger.info('User "%s" started topic "%s"', uid, topic) # ------------------------------------------------------------------------ - # Fill db table 'Topic' with topics from the graph if not already there. + # # ------------------------------------------------------------------------ - def add_missing_topics(self, topics: List[str]) -> None: - with self.db_session() as s: - new_topics = [Topic(id=t) for t in topics - if (t,) not in s.query(Topic.id)] + def _add_missing_topics(self, topics: List[str]) -> None: + ''' + Fill db table 'Topic' with topics from the graph, if new + ''' + with self._db_session() as sess: + new = [Topic(id=t) for t in topics + if (t,) not in sess.query(Topic.id)] - if new_topics: - s.add_all(new_topics) - logger.info(f'Added {len(new_topics)} new topic(s) to the ' - f'database') + if new: + sess.add_all(new) + logger.info('Added %d new topic(s) to the database', len(new)) # ------------------------------------------------------------------------ - # setup and check database contents - # ------------------------------------------------------------------------ - def db_setup(self, db: str) -> None: + def _db_setup(self, database: str) -> None: + '''setup and check database contents''' - logger.info(f'Checking database "{db}":') - if not path.exists(db): + logger.info('Checking database "%s":', database) + if not exists(database): raise LearnException('Database does not exist. ' 'Use "initdb-aprendizations" to create') - engine = sa.create_engine(f'sqlite:///{db}', echo=False) + engine = sa.create_engine(f'sqlite:///{database}', echo=False) self.Session = sa.orm.sessionmaker(bind=engine) try: - with self.db_session() as s: - n: int = s.query(Student).count() - m: int = s.query(Topic).count() - q: int = s.query(Answer).count() + with self._db_session() as sess: + count_students: int = sess.query(Student).count() + count_topics: int = sess.query(Topic).count() + count_answers: int = sess.query(Answer).count() except Exception: - logger.error(f'Database "{db}" not usable!') + logger.error('Database "%s" not usable!', database) raise DatabaseUnusableError() else: - logger.info(f'{n:6} students') - logger.info(f'{m:6} topics') - logger.info(f'{q:6} answers') + logger.info('%6d students', count_students) + logger.info('%6d topics', count_topics) + logger.info('%6d answers', count_answers) - # ======================================================================== - # Populates a digraph. - # - # Nodes are the topic references e.g. 'my/topic' - # 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. # ------------------------------------------------------------------------ - def populate_graph(self, config: Dict[str, Any]) -> None: - g = self.deps # dependency graph + def _populate_graph(self, config: Dict[str, Any]) -> None: + ''' + Populates a digraph. + + Nodes are the topic references e.g. 'my/topic' + 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. + ''' + defaults = { 'type': 'topic', # chapter - # 'file': 'questions.yaml', # deprecated - 'learn_file': 'learn.yaml', - 'practice_file': 'questions.yaml', - + 'file': 'questions.yaml', 'shuffle_questions': True, 'choose': 9999, 'forgetting_factor': 1.0, # no forgetting @@ -400,20 +426,21 @@ class LearnApp(object): # iterate over topics and populate graph topics: Dict[str, Dict] = config.get('topics', {}) - g.add_nodes_from(topics.keys()) + self.deps.add_nodes_from(topics.keys()) for tref, attr in topics.items(): - logger.debug(f' + {tref}') - for d in attr.get('deps', []): - g.add_edge(d, tref) + logger.debug(' + %s', tref) + for dep in attr.get('deps', []): + self.deps.add_edge(dep, tref) - t = g.nodes[tref] # get current topic node - t['name'] = attr.get('name', tref) - t['questions'] = attr.get('questions', []) # FIXME unused?? + topic = self.deps.nodes[tref] # get current topic node + topic['name'] = attr.get('name', tref) + topic['questions'] = attr.get('questions', []) # FIXME unused?? for k, default in defaults.items(): - t[k] = attr.get(k, default) + topic[k] = attr.get(k, default) - t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic + # prefix/topic + topic['path'] = join(self.deps.graph['prefix'], tref) # ======================================================================== @@ -421,47 +448,46 @@ class LearnApp(object): # ======================================================================== # ------------------------------------------------------------------------ - # Buils dictionary of question factories - # - visits each topic in the graph, - # - adds factory for each topic. - # ------------------------------------------------------------------------ - def make_factory(self) -> Dict[str, QFactory]: + def _make_factory(self) -> Dict[str, QFactory]: + ''' + Buils dictionary of question factories + - visits each topic in the graph, + - adds factory for each topic. + ''' logger.info('Building questions factory:') factory = dict() - g = self.deps - for tref in g.nodes(): - factory.update(self.factory_for(tref)) + for tref in self.deps.nodes(): + factory.update(self._factory_for(tref)) - logger.info(f'Factory has {len(factory)} questions') + logger.info('Factory has %s questions', len(factory)) return factory # ------------------------------------------------------------------------ # makes factory for a single topic # ------------------------------------------------------------------------ - def factory_for(self, tref: str) -> Dict[str, QFactory]: + def _factory_for(self, tref: str) -> Dict[str, QFactory]: factory: Dict[str, QFactory] = dict() - g = self.deps - t = g.nodes[tref] # get node + topic = self.deps.nodes[tref] # get node # load questions as list of dicts try: - fullpath: str = path.join(t['path'], t['file']) + fullpath: str = join(topic['path'], topic['file']) except Exception: msg1 = f'Invalid topic "{tref}"' - msg2 = f'Check dependencies of: {", ".join(g.successors(tref))}' + msg2 = 'Check dependencies of: ' + \ + ', '.join(self.deps.successors(tref)) msg = f'{msg1}. {msg2}' logger.error(msg) raise LearnException(msg) - logger.debug(f' Loading {fullpath}') + logger.debug(' Loading %s', fullpath) try: questions: List[QDict] = load_yaml(fullpath) except Exception: - if t['type'] == 'chapter': + if topic['type'] == 'chapter': return factory # chapters may have no "questions" - else: - msg = f'Failed to load "{fullpath}"' - logger.error(msg) - raise LearnException(msg) + msg = f'Failed to load "{fullpath}"' + logger.error(msg) + raise LearnException(msg) if not isinstance(questions, list): msg = f'File "{fullpath}" must be a list of questions' @@ -473,134 +499,162 @@ class LearnApp(object): # undefined are set to topic:n, where n is the question number # within the file localrefs: Set[str] = set() # refs in current file - for i, q in enumerate(questions): - qref = q.get('ref', str(i)) # ref or number + for i, question in enumerate(questions): + qref = question.get('ref', str(i)) # ref or number if qref in localrefs: - msg = f'Duplicate ref "{qref}" in "{t["path"]}"' + msg = f'Duplicate ref "{qref}" in "{topic["path"]}"' raise LearnException(msg) localrefs.add(qref) - q['ref'] = f'{tref}:{qref}' - q['path'] = t['path'] - q.setdefault('append_wrong', t['append_wrong']) + question['ref'] = f'{tref}:{qref}' + question['path'] = topic['path'] + question.setdefault('append_wrong', topic['append_wrong']) # if questions are left undefined, include all. - if not t['questions']: - t['questions'] = [q['ref'] for q in questions] + if not topic['questions']: + topic['questions'] = [q['ref'] for q in questions] - t['choose'] = min(t['choose'], len(t['questions'])) + topic['choose'] = min(topic['choose'], len(topic['questions'])) - for q in questions: - if q['ref'] in t['questions']: - factory[q['ref']] = QFactory(q) - logger.debug(f' + {q["ref"]}') + for question in questions: + if question['ref'] in topic['questions']: + factory[question['ref']] = QFactory(question) + logger.debug(' + %s', question["ref"]) - logger.info(f'{len(t["questions"]):6} questions in {tref}') + logger.info('%6d questions in %s', len(topic["questions"]), tref) return factory # ------------------------------------------------------------------------ def get_login_counter(self, uid: str) -> int: + '''login counter''' # FIXME return int(self.online[uid]['counter']) # ------------------------------------------------------------------------ def get_student_name(self, uid: str) -> str: + '''Get the username''' return self.online[uid].get('name', '') # ------------------------------------------------------------------------ def get_student_state(self, uid: str) -> List[Dict[str, Any]]: + '''Get the knowledge state of a given user''' return self.online[uid]['state'].get_knowledge_state() # ------------------------------------------------------------------------ def get_student_progress(self, uid: str) -> float: + '''Get the current topic progress of a given user''' return float(self.online[uid]['state'].get_topic_progress()) # ------------------------------------------------------------------------ def get_current_question(self, uid: str) -> Optional[Question]: + '''Get the current question of a given user''' q: Optional[Question] = self.online[uid]['state'].get_current_question() return q # ------------------------------------------------------------------------ def get_current_question_id(self, uid: str) -> str: + '''Get id of the current question for a given user''' return str(self.online[uid]['state'].get_current_question()['qid']) # ------------------------------------------------------------------------ def get_student_question_type(self, uid: str) -> str: + '''Get type of the current question for a given user''' return str(self.online[uid]['state'].get_current_question()['type']) # ------------------------------------------------------------------------ - def get_student_topic(self, uid: str) -> str: - return str(self.online[uid]['state'].get_current_topic()) + # def get_student_topic(self, uid: str) -> str: + # return str(self.online[uid]['state'].get_current_topic()) # ------------------------------------------------------------------------ def get_student_course_title(self, uid: str) -> str: + '''get the title of the current course for a given user''' return str(self.online[uid]['state'].get_current_course_title()) # ------------------------------------------------------------------------ def get_current_course_id(self, uid: str) -> Optional[str]: + '''get the current course (id) of a given user''' cid: Optional[str] = self.online[uid]['state'].get_current_course_id() return cid # ------------------------------------------------------------------------ - def get_topic_name(self, ref: str) -> str: - return str(self.deps.nodes[ref]['name']) + # def get_topic_name(self, ref: str) -> str: + # return str(self.deps.nodes[ref]['name']) # ------------------------------------------------------------------------ def get_current_public_dir(self, uid: str) -> str: + ''' + Get the path for the 'public' directory of the current topic of the + given user. + E.g. if the user has the active topic 'xpto', + then returns 'path/to/xpto/public'. + ''' topic: str = self.online[uid]['state'].get_current_topic() prefix: str = self.deps.graph['prefix'] - return path.join(prefix, topic, 'public') + return join(prefix, topic, 'public') # ------------------------------------------------------------------------ def get_courses(self) -> Dict[str, Dict[str, Any]]: + ''' + Get dictionary with all courses {'course1': {...}, 'course2': {...}} + ''' return self.courses # ------------------------------------------------------------------------ def get_course(self, course_id: str) -> Dict[str, Any]: + ''' + Get dictionary {'title': ..., 'description':..., 'goals':...} + ''' return self.courses[course_id] # ------------------------------------------------------------------------ def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]: - - logger.info(f'User "{uid}" get rankings for {course_id}') - with self.db_session() as s: - students = s.query(Student.id, Student.name).all() - - # topic progress - student_topics = s.query(StudentTopic.student_id, - StudentTopic.topic_id, - StudentTopic.level, - StudentTopic.date).all() + ''' + Returns rankings for a certain course_id. + User where uid have <=2 chars are considered ghosts are hidden from + the rankings. This is so that there can be users for development or + testing purposes, which are not real users. + The user_id of real students must have >2 chars. + ''' + + logger.info('User "%s" get rankings for %s', uid, course_id) + with self._db_session() as sess: + # all students in the database FIXME only with answers of this course + students = sess.query(Student.id, Student.name).all() + + # topic levels FIXME only topics of this course + student_topics = sess.query(StudentTopic.student_id, + StudentTopic.topic_id, + StudentTopic.level, + StudentTopic.date).all() # answer performance - total = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). - group_by(Answer.student_id). - all()) - right = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)). - filter(Answer.grade == 1.0). - group_by(Answer.student_id). - all()) + total = dict(sess.query(Answer.student_id, + sa.func.count(Answer.ref)) \ + .group_by(Answer.student_id) \ + .all()) + right = dict(sess.query(Answer.student_id, + sa.func.count(Answer.ref)) \ + .filter(Answer.grade == 1.0) \ + .group_by(Answer.student_id) \ + .all()) # compute percentage of right answers - perf: Dict[str, float] = {u: right.get(u, 0.0)/total[u] + perf: Dict[str, float] = {u: right.get(u, 0.0) / total[u] for u in total} # compute topic progress now = datetime.now() goals = self.courses[course_id]['goals'] - prog: DefaultDict[str, float] = defaultdict(int) + progress: DefaultDict[str, float] = defaultdict(int) - for u, topic, level, date in student_topics: + for student, topic, level, date in student_topics: if topic in goals: date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f") - prog[u] += level**(now - date).days / len(goals) - - ghostuser = len(uid) <= 2 # ghosts are invisible to students - rankings = [(u, name, prog[u], perf.get(u, 0.0)) - for u, name in students - if u in prog - and (len(u) > 2 or ghostuser) and u != '0' ] - rankings.sort(key=lambda x: x[2], reverse=True) - return rankings + progress[student] += level**(now - date).days / len(goals) + + return sorted(((u, name, progress[u], perf.get(u, 0.0)) + for u, name in students + if u in progress and (len(u) > 2 or len(uid) <= 2)), + key=lambda x: x[2], reverse=True) # ------------------------------------------------------------------------ diff --git a/aprendizations/serve.py b/aprendizations/serve.py index 181beb1..53f0261 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -1,3 +1,7 @@ +''' +Webserver +''' + # python standard library import asyncio @@ -5,7 +9,7 @@ import base64 import functools import logging.config import mimetypes -from os import path +from os.path import join, dirname, expanduser import signal import sys from typing import List, Optional, Union @@ -16,8 +20,9 @@ import tornado.web from tornado.escape import to_unicode # this project -from .tools import md_to_html -from . import APP_NAME +from aprendizations.tools import md_to_html +from aprendizations.learnapp import LearnException +from aprendizations import APP_NAME # setup logger for this module @@ -25,39 +30,39 @@ logger = logging.getLogger(__name__) # ---------------------------------------------------------------------------- -# Decorator used to restrict access to the administrator -# ---------------------------------------------------------------------------- def admin_only(func): + ''' + Decorator used to restrict access to the administrator + ''' @functools.wraps(func) def wrapper(self, *args, **kwargs): if self.current_user != '0': raise tornado.web.HTTPError(403) # forbidden - else: - func(self, *args, **kwargs) + func(self, *args, **kwargs) return wrapper # ============================================================================ -# WebApplication - Tornado Web Server -# ============================================================================ class WebApplication(tornado.web.Application): - + ''' + WebApplication - Tornado Web Server + ''' def __init__(self, learnapp, debug=False): handlers = [ - (r'/login', LoginHandler), - (r'/logout', LogoutHandler), + (r'/login', LoginHandler), + (r'/logout', LogoutHandler), (r'/change_password', ChangePasswordHandler), - (r'/question', QuestionHandler), # render question - (r'/rankings', RankingsHandler), # rankings table - (r'/topic/(.+)', TopicHandler), # start topic - (r'/file/(.+)', FileHandler), # serve file - (r'/courses', CoursesHandler), # show list of courses - (r'/course/(.*)', CourseHandler), # show course topics - (r'/', RootHandler), # redirects + (r'/question', QuestionHandler), # render question + (r'/rankings', RankingsHandler), # rankings table + (r'/topic/(.+)', TopicHandler), # start topic + (r'/file/(.+)', FileHandler), # serve file + (r'/courses', CoursesHandler), # show list of courses + (r'/course/(.*)', CourseHandler), # show course topics + (r'/', RootHandler), # redirects ] settings = { - 'template_path': path.join(path.dirname(__file__), 'templates'), - 'static_path': path.join(path.dirname(__file__), 'static'), + 'template_path': join(dirname(__file__), 'templates'), + 'static_path': join(dirname(__file__), 'static'), 'static_url_prefix': '/static/', 'xsrf_cookies': True, 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), @@ -71,30 +76,37 @@ class WebApplication(tornado.web.Application): # ============================================================================ # Handlers # ============================================================================ - -# ---------------------------------------------------------------------------- -# Base handler common to all handlers. -# ---------------------------------------------------------------------------- +# pylint: disable=abstract-method class BaseHandler(tornado.web.RequestHandler): + ''' + Base handler common to all handlers. + ''' @property def learn(self): + '''easier access to learnapp''' return self.application.learn def get_current_user(self): - cookie = self.get_secure_cookie('user') - if cookie: - uid = cookie.decode('utf-8') + '''called on every method decorated with @tornado.web.authenticated''' + user_cookie = self.get_secure_cookie('user') + if user_cookie is not None: + uid = user_cookie.decode('utf-8') counter = self.get_secure_cookie('counter').decode('utf-8') if counter == str(self.learn.get_login_counter(uid)): return uid + return None # ---------------------------------------------------------------------------- -# /rankings -# ---------------------------------------------------------------------------- class RankingsHandler(BaseHandler): + ''' + Handles rankings page + ''' @tornado.web.authenticated def get(self): + ''' + Renders list of students that have answers in this course. + ''' uid = self.current_user current_course = self.learn.get_current_course_id(uid) course_id = self.get_query_argument('course', default=current_course) @@ -110,23 +122,33 @@ class RankingsHandler(BaseHandler): # ---------------------------------------------------------------------------- -# /auth/login +# # ---------------------------------------------------------------------------- class LoginHandler(BaseHandler): + ''' + Handles /login + ''' def get(self): + ''' + Renders login page + ''' self.render('login.html', appname=APP_NAME, error='') async def post(self): - uid = self.get_body_argument('uid').lstrip('l') - pw = self.get_body_argument('pw') + ''' + Perform authentication and redirects to application if successful + ''' - login_ok = await self.learn.login(uid, pw) + userid = self.get_body_argument('uid').lstrip('l') + passwd = self.get_body_argument('pw') + + login_ok = await self.learn.login(userid, passwd) if login_ok: - counter = str(self.learn.get_login_counter(uid)) - self.set_secure_cookie('user', uid) + counter = str(self.learn.get_login_counter(userid)) + self.set_secure_cookie('user', userid) self.set_secure_cookie('counter', counter) self.redirect('/') else: @@ -136,11 +158,15 @@ class LoginHandler(BaseHandler): # ---------------------------------------------------------------------------- -# /auth/logout -# ---------------------------------------------------------------------------- class LogoutHandler(BaseHandler): + ''' + Handles /logout + ''' @tornado.web.authenticated def get(self): + ''' + clears cookies and removes user session + ''' self.clear_cookie('user') self.clear_cookie('counter') self.redirect('/') @@ -151,12 +177,18 @@ class LogoutHandler(BaseHandler): # ---------------------------------------------------------------------------- class ChangePasswordHandler(BaseHandler): + ''' + Handles password change + ''' @tornado.web.authenticated async def post(self): - uid = self.current_user - pw = self.get_body_arguments('new_password')[0] + ''' + Tries to perform password change and then replies success/fail status + ''' + userid = self.current_user + passwd = self.get_body_arguments('new_password')[0] - changed_ok = await self.learn.change_password(uid, pw) + changed_ok = await self.learn.change_password(userid, passwd) if changed_ok: notification = self.render_string( 'notification.html', @@ -174,45 +206,53 @@ class ChangePasswordHandler(BaseHandler): # ---------------------------------------------------------------------------- -# / -# redirects to appropriate place -# ---------------------------------------------------------------------------- class RootHandler(BaseHandler): + ''' + Handles root / + ''' @tornado.web.authenticated def get(self): + '''Simply redirects to the main entrypoint''' self.redirect('/courses') # ---------------------------------------------------------------------------- -# /courses -# Shows a list of available courses -# ---------------------------------------------------------------------------- class CoursesHandler(BaseHandler): + ''' + Handles /courses + ''' @tornado.web.authenticated def get(self): + '''Renders list of available courses''' uid = self.current_user self.render('courses.html', appname=APP_NAME, uid=uid, name=self.learn.get_student_name(uid), courses=self.learn.get_courses(), + # courses_progress= ) -# ---------------------------------------------------------------------------- -# /course/... -# Start a given course and show list of topics -# ---------------------------------------------------------------------------- +# ============================================================================ class CourseHandler(BaseHandler): + ''' + Handles a particular course to show the topics table + ''' + @tornado.web.authenticated def get(self, course_id): + ''' + Handles get /course/... + Starts a given course and show list of topics + ''' uid = self.current_user if course_id == '': course_id = self.learn.get_current_course_id(uid) try: self.learn.start_course(uid, course_id) - except KeyError: + except LearnException: self.redirect('/courses') self.render('maintopics-table.html', @@ -225,17 +265,21 @@ class CourseHandler(BaseHandler): ) -# ---------------------------------------------------------------------------- -# /topic/... -# Start a given topic -# ---------------------------------------------------------------------------- +# ============================================================================ class TopicHandler(BaseHandler): + ''' + Handles a topic + ''' @tornado.web.authenticated async def get(self, topic): + ''' + Handles get /topic/... + Starts a given topic + ''' uid = self.current_user try: - await self.learn.start_topic(uid, topic) + await self.learn.start_topic(uid, topic) # FIXME GET should not modify state... except KeyError: self.redirect('/topics') @@ -243,31 +287,34 @@ class TopicHandler(BaseHandler): appname=APP_NAME, uid=uid, name=self.learn.get_student_name(uid), - # course_title=self.learn.get_student_course_title(uid), course_id=self.learn.get_current_course_id(uid), ) -# ---------------------------------------------------------------------------- -# Serves files from the /public subdir of the topics. -# ---------------------------------------------------------------------------- +# ============================================================================ class FileHandler(BaseHandler): + ''' + Serves files from the /public subdir of the topics. + ''' @tornado.web.authenticated async def get(self, filename): + ''' + Serves files from /public subdirectories of a particular topic + ''' uid = self.current_user public_dir = self.learn.get_current_public_dir(uid) - filepath = path.expanduser(path.join(public_dir, filename)) + filepath = expanduser(join(public_dir, filename)) content_type = mimetypes.guess_type(filename)[0] try: - with open(filepath, 'rb') as f: - data = f.read() + with open(filepath, 'rb') as file: + data = file.read() except FileNotFoundError: - logger.error(f'File not found: {filepath}') + logger.error('File not found: %s', filepath) except PermissionError: - logger.error(f'No permission: {filepath}') + logger.error('No permission: %s', filepath) except Exception: - logger.error(f'Error reading: {filepath}') + logger.error('Error reading: %s', filepath) raise else: self.set_header("Content-Type", content_type) @@ -275,10 +322,11 @@ class FileHandler(BaseHandler): await self.flush() -# ---------------------------------------------------------------------------- -# respond to AJAX to get a JSON question -# ---------------------------------------------------------------------------- +# ============================================================================ class QuestionHandler(BaseHandler): + ''' + Responds to AJAX to get a JSON question + ''' templates = { 'checkbox': 'question-checkbox.html', 'radio': 'question-radio.html', @@ -294,27 +342,27 @@ class QuestionHandler(BaseHandler): } # ------------------------------------------------------------------------ - # GET - # gets question to render. If there are no more questions in the topic - # shows an animated trophy - # ------------------------------------------------------------------------ @tornado.web.authenticated async def get(self): + ''' + Gets question to render. + Shows an animated trophy if there are no more questions in the topic. + ''' logger.debug('[QuestionHandler]') user = self.current_user - q = await self.learn.get_question(user) + question = await self.learn.get_question(user) # show current question - if q is not None: - qhtml = self.render_string(self.templates[q['type']], - question=q, md=md_to_html) + if question is not None: + qhtml = self.render_string(self.templates[question['type']], + question=question, md=md_to_html) response = { 'method': 'new_question', 'params': { - 'type': q['type'], + 'type': question['type'], 'question': to_unicode(qhtml), 'progress': self.learn.get_student_progress(user), - 'tries': q['tries'], + 'tries': question['tries'], } } @@ -331,20 +379,20 @@ class QuestionHandler(BaseHandler): self.write(response) # ------------------------------------------------------------------------ - # POST - # corrects answer and returns status: right, wrong, try_again - # does not move to next question. - # ------------------------------------------------------------------------ @tornado.web.authenticated async def post(self) -> None: + ''' + Corrects answer and returns status: right, wrong, try_again + Does not move to next question. + ''' user = self.current_user answer = self.get_body_arguments('answer') # list qid = self.get_body_arguments('qid')[0] - logger.debug(f'[QuestionHandler] answer={answer}') + # logger.debug('[QuestionHandler] answer=%s', answer) # --- check if browser opened different questions simultaneously if qid != self.learn.get_current_question_id(user): - logger.info(f'User {user} desynchronized questions') + logger.warning('User %s desynchronized questions', user) self.write({ 'method': 'invalid', 'params': { @@ -370,51 +418,55 @@ class QuestionHandler(BaseHandler): ans = answer # --- check answer (nonblocking) and get corrected question and action - q = await self.learn.check_answer(user, ans) + question = await self.learn.check_answer(user, ans) # --- built response to return - response = {'method': q['status'], 'params': {}} + response = {'method': question['status'], 'params': {}} - if q['status'] == 'right': # get next question in the topic - comments_html = self.render_string( - 'comments-right.html', comments=q['comments'], md=md_to_html) + if question['status'] == 'right': # get next question in the topic + comments = self.render_string('comments-right.html', + comments=question['comments'], + md=md_to_html) - solution_html = self.render_string( - 'solution.html', solution=q['solution'], md=md_to_html) + solution = self.render_string('solution.html', + solution=question['solution'], + md=md_to_html) response['params'] = { - 'type': q['type'], + 'type': question['type'], 'progress': self.learn.get_student_progress(user), - 'comments': to_unicode(comments_html), - 'solution': to_unicode(solution_html), - 'tries': q['tries'], + 'comments': to_unicode(comments), + 'solution': to_unicode(solution), + 'tries': question['tries'], } - elif q['status'] == 'try_again': - comments_html = self.render_string( - 'comments.html', comments=q['comments'], md=md_to_html) + elif question['status'] == 'try_again': + comments = self.render_string('comments.html', + comments=question['comments'], + md=md_to_html) response['params'] = { - 'type': q['type'], + 'type': question['type'], 'progress': self.learn.get_student_progress(user), - 'comments': to_unicode(comments_html), - 'tries': q['tries'], + 'comments': to_unicode(comments), + 'tries': question['tries'], } - elif q['status'] == 'wrong': # no more tries - comments_html = self.render_string( - 'comments.html', comments=q['comments'], md=md_to_html) + elif question['status'] == 'wrong': # no more tries + comments = self.render_string('comments.html', + comments=question['comments'], + md=md_to_html) - solution_html = self.render_string( - 'solution.html', solution=q['solution'], md=md_to_html) + solution = self.render_string( + 'solution.html', solution=question['solution'], md=md_to_html) response['params'] = { - 'type': q['type'], + 'type': question['type'], 'progress': self.learn.get_student_progress(user), - 'comments': to_unicode(comments_html), - 'solution': to_unicode(solution_html), - 'tries': q['tries'], + 'comments': to_unicode(comments), + 'solution': to_unicode(solution), + 'tries': question['tries'], } else: - logger.error(f'Unknown question status: {q["status"]}') + logger.error('Unknown question status: %s', question["status"]) self.write(response) @@ -422,29 +474,29 @@ class QuestionHandler(BaseHandler): # ---------------------------------------------------------------------------- # Signal handler to catch Ctrl-C and abort server # ---------------------------------------------------------------------------- -def signal_handler(signal, frame) -> None: - r = input(' --> Stop webserver? (yes/no) ').lower() - if r == 'yes': +def signal_handler(*_) -> None: + ''' + Catches Ctrl-C and stops webserver + ''' + reply = input(' --> Stop webserver? (yes/no) ') + if reply.lower() == 'yes': tornado.ioloop.IOLoop.current().stop() - logger.critical('Webserver stopped.') + logging.critical('Webserver stopped.') sys.exit(0) - else: - logger.info('Abort canceled...') # ---------------------------------------------------------------------------- -def run_webserver(app, - ssl, - port: int = 8443, - debug: bool = False) -> None: +def run_webserver(app, ssl, port: int = 8443, debug: bool = False) -> None: + ''' + Starts and runs webserver until a SIGINT signal (Ctrl-C) is received. + ''' # --- create web application try: webapp = WebApplication(app, debug=debug) except Exception: logger.critical('Failed to start web application.') - raise - # sys.exit(1) + sys.exit(1) else: logger.info('Web application started (tornado.web.Application)') @@ -460,14 +512,12 @@ def run_webserver(app, try: httpserver.listen(port) except OSError: - logger.critical(f'Cannot bind port {port}. Already in use?') + logger.critical('Cannot bind port %d. Already in use?', port) sys.exit(1) - else: - logger.info(f'HTTP server listening on port {port}') # --- run webserver + logger.info('Webserver listening on %d... (Ctrl-C to stop)', port) signal.signal(signal.SIGINT, signal_handler) - logger.info('Webserver running... (Ctrl-C to stop)') try: tornado.ioloop.IOLoop.current().start() # running... diff --git a/aprendizations/static/css/topic.css b/aprendizations/static/css/topic.css index 954d256..107fb5f 100644 --- a/aprendizations/static/css/topic.css +++ b/aprendizations/static/css/topic.css @@ -1,12 +1,4 @@ -.progress { - /*position: fixed;*/ - top: 0; - height: 70px; - border-radius: 0px; -} body { - margin: 0; - padding-top: 0px; margin-bottom: 120px; /* Margin bottom by footer height */ } @@ -19,10 +11,6 @@ body { /*background-color: #f5f5f5;*/ } -html { - position: relative; - min-height: 100%; -} .CodeMirror { border: 1px solid #eee; height: auto; diff --git a/aprendizations/templates/topic.html b/aprendizations/templates/topic.html index 44fe4ea..7014850 100644 --- a/aprendizations/templates/topic.html +++ b/aprendizations/templates/topic.html @@ -1,37 +1,26 @@ - - - + + {{appname}} - - + - - - - - - - - + + @@ -39,10 +28,23 @@ + + + + + + + + + +
+
+
+ -
-
-
- -
+
@@ -101,5 +99,4 @@
- \ No newline at end of file -- libgit2 0.21.2