From fcdedf5af3c8f77e05d228b96446467568e4d8d9 Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Thu, 23 Feb 2017 20:17:03 +0000 Subject: [PATCH] - Reads config.yaml with dependency graph - Each question has it's own factory - Questions are added to a list in each node of the graph - Database has a topics table and N-M relationship to students --- BUGS.md | 6 +++++- app.py | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------- config/logger.yaml | 18 ++++++++++++++---- knowledge.py | 39 ++++++++++++++++++++++++++++++++------- models.py | 24 ++++++++++++++++++++++++ questions.py | 240 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------------------- serve.py | 10 ++++++---- static/sounds/correct.mp3 | Bin 23404 -> 0 bytes static/sounds/intro.mp3 | Bin 13375 -> 0 bytes static/sounds/wrong.mp3 | Bin 5850 -> 0 bytes 10 files changed, 304 insertions(+), 146 deletions(-) delete mode 100644 static/sounds/correct.mp3 delete mode 100644 static/sounds/intro.mp3 delete mode 100644 static/sounds/wrong.mp3 diff --git a/BUGS.md b/BUGS.md index 0816177..8f885b5 100644 --- a/BUGS.md +++ b/BUGS.md @@ -1,18 +1,22 @@ BUGS: +- servir ficheiros de public temporariamente - se students.db não existe, rebenta. -- questions hardcoded in LearnApp. - database hardcoded in LearnApp. - implementar xsrf. Ver [http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection]() TODO: +- mostrar comments quando falha a resposta - configuração e linha de comando. - como gerar uma sequencia de perguntas? - generators not working: bcrypt (ver blog) SOLVED: +- path dos generators scripts mal construido +- questions hardcoded in LearnApp. +- Factory para cada pergunta individual em vez de pool - implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona, enter submete. - logging - textarea tem codigo para preencher o texto, mas ja não é necessário porque pergunta não é reloaded. diff --git a/app.py b/app.py index bfaff8d..6582b2b 100644 --- a/app.py +++ b/app.py @@ -2,6 +2,7 @@ # python standard library from contextlib import contextmanager # `with` statement in db sessions import logging +from os import path # user installed libraries try: @@ -15,8 +16,10 @@ except ImportError: sys.exit(1) # this project -from models import Student, Answer +from models import Student, Answer, Topic, StudentTopic from knowledge import Knowledge +from questions import QFactory +from tools import load_yaml # setup logger for this module logger = logging.getLogger(__name__) @@ -33,20 +36,7 @@ class LearnApp(object): self.setup_db('students.db') # FIXME # build dependency graph - self.build_dependency_graph({'uevora/lei'}) # FIXME - - # ------------------------------------------------------------------------ - def setup_db(self, db): - engine = create_engine(f'sqlite:///{db}', echo=False) - self.Session = sessionmaker(bind=engine) - try: - with self.db_session() as s: - n = s.query(Student).count() - except Exception as e: - logger.critical('Database not usable.') - raise e - else: - logger.info(f'Database has {n} students registered.') + self.build_dependency_graph('demo/config.yaml') # FIXME # ------------------------------------------------------------------------ def login(self, uid, try_pw): @@ -62,17 +52,21 @@ class LearnApp(object): logger.info(f'User "{uid}" wrong password.') return False # wrong password + student_state = s.query(StudentTopic).filter(StudentTopic.student_id == uid).all() + # success self.online[uid] = { 'name': student.name, 'number': student.id, - 'knowledge': Knowledge(), # FIXME initial state? + 'state': Knowledge(self.depgraph, student_state), } + logger.info(f'User "{uid}" logged in') return True # ------------------------------------------------------------------------ # logout def logout(self, uid): + logger.info(f'User "{uid}" logged out') del self.online[uid] # FIXME save current state # ------------------------------------------------------------------------ @@ -82,10 +76,12 @@ class LearnApp(object): # ------------------------------------------------------------------------ # check answer and if correct returns new question, otherise returns None def check_answer(self, uid, answer): - knowledge = self.online[uid]['knowledge'] + logger.debug(f'check_answer("{uid}", "{answer}")') + knowledge = self.online[uid]['state'] current_question = knowledge.check_answer(answer) if current_question is not None: + logger.debug('check_answer: saving answer to db ...') with self.db_session() as s: s.add(Answer( ref=current_question['ref'], @@ -94,7 +90,9 @@ class LearnApp(object): finishtime=str(current_question['finish_time']), student_id=uid)) s.commit() + logger.debug('check_answer: saving done') + logger.debug('check_answer: will return knowledge.new_question') return knowledge.new_question() # ------------------------------------------------------------------------ @@ -106,42 +104,63 @@ class LearnApp(object): try: yield session session.commit() - except: + except Exception as e: session.rollback() - raise + raise e finally: session.close() # ------------------------------------------------------------------------ # Receives a set of topics (strings like "math/algebra"), # and recursively adds dependencies to the dependency graph - def build_dependency_graph(self, topics=set()): - g = nx.DiGraph() - - # add nodes "recursively" following the config.yaml files - while topics: - t = topics.pop() # take one topic - if t in g.nodes(): # skip it if already in the graph - continue - - # get configuration dictionary for this topic - # dictionary has keys: type, title, depends - try: - with open(f'topics/{t}/config.yaml', 'r') as f: - logger.info(f'Loading {t}') - config = yaml.load(f) - except FileNotFoundError: - logger.error(f'Not found: topics/{t}/config.yaml') - continue - - config.setdefault('depends', set()) # make sure 'depends' key exists - topics.update(config['depends']) - g.add_node(t, config=config) - - # add edges topic -> dependency - for t in g.nodes(): - deps = g.node[t]['config']['depends'] - g.add_edges_from([(t, d) for d in deps]) + def build_dependency_graph(self, config_file): + logger.debug(f'build_dependency_graph("{config_file}")') + + # Load configuration file + try: + with open(config_file, 'r') as f: + logger.info(f'Loading configuration file "{config_file}"') + config = yaml.load(f) + except FileNotFoundError as e: + logger.error(f'File not found: "{config_file}"') + raise e + + prefix = config['path'] # FIXME default if does not exist? + g = nx.DiGraph(path=prefix) + + # Build dependency graph + deps = config.get('dependencies', {}) + for n,dd in deps.items(): + g.add_edges_from((n,d) for d in dd) + + # Builds factories for each node + for n in g.nodes_iter(): + fullpath = path.join(prefix, n) + # if name is directory defaults to "prefix/questions.yaml" + if path.isdir(fullpath): + filename = path.join(fullpath, "questions.yaml") + + if path.isfile(filename): + logger.info(f'Loading questions from "{filename}"') + questions = load_yaml(filename, default=[]) + for q in questions: + q['path'] = fullpath + + g.node[n]['factory'] = [QFactory(q) for q in questions] self.depgraph = g - logger.info(f'Graph has {g.number_of_nodes()} nodes and {g.number_of_edges()} edges') + + # ------------------------------------------------------------------------ + # setup and check database + def setup_db(self, db): + engine = create_engine(f'sqlite:///{db}', echo=False) + self.Session = sessionmaker(bind=engine) + try: + with self.db_session() as s: + n = s.query(Student).count() + except Exception as e: + logger.critical('Database not usable.') + raise e + else: + logger.info(f'Database has {n} students registered.') + diff --git a/config/logger.yaml b/config/logger.yaml index 445082a..eb6190d 100644 --- a/config/logger.yaml +++ b/config/logger.yaml @@ -5,11 +5,11 @@ formatters: void: format: '' standard: - format: '%(asctime)s | %(levelname)-8s | %(name)-8s | %(message)s' + format: '%(asctime)s | %(levelname)-8s | %(name)-9s | %(message)s' handlers: default: - level: 'INFO' + level: 'DEBUG' class: 'logging.StreamHandler' formatter: 'standard' stream: 'ext://sys.stdout' @@ -21,12 +21,12 @@ loggers: 'app': handlers: ['default'] - level: 'INFO' + level: 'DEBUG' propagate: False 'questions': handlers: ['default'] - level: 'INFO' + level: 'DEBUG' propagate: False 'tools': @@ -34,3 +34,13 @@ loggers: level: 'INFO' propagate: False + 'knowledge': + handlers: ['default'] + level: 'DEBUG' + propagate: False + + # 'root': + # handlers: ['default'] + # level: 'DEBUG' + # propagate: False + # \ No newline at end of file diff --git a/knowledge.py b/knowledge.py index 4574b11..c5b340b 100644 --- a/knowledge.py +++ b/knowledge.py @@ -2,36 +2,61 @@ # python standard library import random from datetime import datetime +import logging + +# libraries +import networkx as nx # this project import questions +# setup logger for this module +logger = logging.getLogger(__name__) +# ---------------------------------------------------------------------------- # contains the kowledge state of each student. class Knowledge(object): - def __init__(self): - self.factory = questions.QuestionFactory() - self.factory.load_files(['questions.yaml'], 'demo') # FIXME + def __init__(self, depgraph, state={}): + self.depgraph = depgraph + self.state = state # {node: level, node: level, ...} self.current_question = None + # self.seq = nx.topological_sort(self.depgraph) + def get_current_question(self): return self.current_question + def get_knowledge_state(self): + return self.state + # --- generates a new question given the current state def new_question(self): - # FIXME + logger.debug('new_question()') if self.current_question is None or self.current_question.get('grade', 0.0) > 0.9999: - questions = list(self.factory) - self.current_question = self.factory.generate(random.choice(questions)) + g = self.depgraph + # choose topic, ie, node of the graph + # print(g.nodes()) + topic = random.choice(g.nodes()) # FIXME + # print(topic) + # choose question from that topic + question = random.choice(g.node[topic]['factory']) + # print(question) + + self.current_question = question.generate() + # print(self.current_question) + + # self.current_question = g.node[topic]['factory'].generate(ref) self.current_question['start_time'] = datetime.now() - return self.current_question # --- checks answer and updates knowledge state # returns current question with correction, time and comments updated def check_answer(self, answer): + logger.debug(f'check_answer("{answer}")') question = self.current_question if question is not None: question['finish_time'] = datetime.now() question.correct(answer) + + # logger.debug(f'check_answer returning') return question diff --git a/models.py b/models.py index 62d7f8d..ecf163e 100644 --- a/models.py +++ b/models.py @@ -10,6 +10,17 @@ from sqlalchemy.orm import relationship Base = declarative_base() # --------------------------------------------------------------------------- +class StudentTopic(Base): + __tablename__ = 'studenttopic' + student_id = Column(String, ForeignKey('students.id'), primary_key=True) + topic_id = Column(String, ForeignKey('topics.id'), primary_key=True) + level = Column(Float) + + # --- + student = relationship('Student', back_populates='topics') + topic = relationship('Topic', back_populates='students') + +# --------------------------------------------------------------------------- # Registered students # --------------------------------------------------------------------------- class Student(Base): @@ -20,6 +31,7 @@ class Student(Base): # --- answers = relationship('Answer', back_populates='student') + topics = relationship('StudentTopic', back_populates='student') def __repr__(self): return f'''Student: @@ -52,3 +64,15 @@ class Answer(Base): finishtime: "{self.finishtime}" student_id: "{self.student_id}"''' +# --------------------------------------------------------------------------- +# Table with student state +# --------------------------------------------------------------------------- +class Topic(Base): + __tablename__ = 'topics' + id = Column(String, primary_key=True) + + # --- + students = relationship('StudentTopic', back_populates='topic') + + + diff --git a/questions.py b/questions.py index 15a6baa..99f0d80 100644 --- a/questions.py +++ b/questions.py @@ -49,8 +49,6 @@ else: from tools import load_yaml, run_script - - # =========================================================================== # Questions derived from Question are already instantiated and ready to be # presented to students. @@ -320,6 +318,7 @@ class QuestionTextArea(Question): #------------------------------------------------------------------------ def __init__(self, q): + logger.debug('QuestionTextArea.__init__()') super().__init__(q) self.set_defaults({ @@ -381,14 +380,14 @@ class QuestionInformation(Question): + # =========================================================================== -# This class contains a pool of questions generators from which particular -# Question() instances are generated using QuestionsFactory.generate(ref). +# Question Factory # =========================================================================== -class QuestionFactory(dict): +class QFactory(object): # Depending on the type of question, a different question class will be # instantiated. All these classes derive from the base class `Question`. - types = { + _types = { 'radio' : QuestionRadio, 'checkbox' : QuestionCheckbox, 'text' : QuestionText, @@ -399,71 +398,20 @@ class QuestionFactory(dict): 'information': QuestionInformation, 'warning' : QuestionInformation, 'alert' : QuestionInformation, - } - - # ----------------------------------------------------------------------- - def __init__(self): - super().__init__() - - # ----------------------------------------------------------------------- - # Add single question provided in a dictionary. - # After this, each question will have at least 'ref' and 'type' keys. - # ----------------------------------------------------------------------- - def add(self, question): - # if ref missing try ref='/path/file.yaml:3' - try: - question.setdefault('ref', question['filename'] + ':' + str(question['index'])) - except KeyError: - logger.error('Missing "ref". Cannot add question to the pool.') - return - - # check duplicate references - if question['ref'] in self: - logger.error('Duplicate reference "{0}". Replacing the original one!'.format(question['ref'])) - - question.setdefault('type', 'information') - - self[question['ref']] = question - logger.debug('Added question "{0}" to the pool.'.format(question['ref'])) - - # ----------------------------------------------------------------------- - # load single YAML questions file - # ----------------------------------------------------------------------- - def load_file(self, filename, questions_dir=''): - f = path.normpath(path.join(questions_dir, filename)) - questions = load_yaml(f, default=[]) - - n = 0 - for i, q in enumerate(questions): - if isinstance(q, dict): - q.update({ - 'filename': filename, - 'path': questions_dir, - 'index': i # position in the file, 0 based - }) - self.add(q) # add question - n += 1 # counter - else: - logger.error('Question index {0} from file {1} is not a dictionary. Skipped!'.format(i, filename)) + } - logger.info('Loaded {0} questions from "{1}".'.format(n, filename)) - - # ----------------------------------------------------------------------- - # load multiple YAML question files - # ----------------------------------------------------------------------- - def load_files(self, files, questions_dir=''): - for filename in files: - self.load_file(filename, questions_dir) + def __init__(self, question_dict): + self.question = question_dict # ----------------------------------------------------------------------- # Given a ref returns an instance of a descendent of Question(), # i.e. a question object (radio, checkbox, ...). # ----------------------------------------------------------------------- - def generate(self, ref): - + def generate(self): + logger.debug('generate()') # Shallow copy so that script generated questions will not replace # the original generators - q = self[ref].copy() + q = self.question.copy() # If question is of generator type, an external program will be run # which will print a valid question in yaml format to stdout. This @@ -471,27 +419,153 @@ class QuestionFactory(dict): if q['type'] == 'generator': logger.debug('Running script to generate question "{0}".'.format(q['ref'])) q.setdefault('arg', '') # optional arguments will be sent to stdin - script = path.normpath(path.join(q['path'], q['script'])) + print(q['path']) + print(q['script']) + script = path.join(q['path'], q['script']) out = run_script(script=script, stdin=q['arg']) - try: - q.update(out) - except: - q.update({ - 'type': 'alert', - 'title': 'Erro interno', - 'text': 'Ocorreu um erro a gerar esta pergunta.' - }) - # The generator was replaced by a question but not yet instantiated + q.update(out) + # try: + # q.update(out) + # except: + # logger.error(f'Question generator "{q["ref"]}"') + # q.update({ + # 'type': 'alert', + # 'title': 'Erro interno', + # 'text': 'Ocorreu um erro a gerar esta pergunta.' + # }) # Finally we create an instance of Question() - try: - qinstance = self.types[q['type']](q) # instance with correct class - except KeyError as e: - logger.error('Unknown question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) - raise e - except: - logger.error('Failed to create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) - else: - logger.debug('Generated question "{}".'.format(ref)) - return qinstance + logger.debug('create instance...') + # try: + # qinstance = self._types[q['type']](q) # instance with correct class + # except KeyError as e: + # logger.error('Unknown question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) + # raise e + # except: + # logger.error('Failed to create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) + # else: + # logger.debug('Generated question "{}".'.format(ref)) + # return qinstance + qinstance = self._types[q['type']](q) # instance with correct class + logger.debug('returning') + return qinstance + + +# =========================================================================== +# This class contains a pool of questions generators from which particular +# Question() instances are generated using QuestionsFactory.generate(ref). +# =========================================================================== +# class QuestionFactory(dict): +# # Depending on the type of question, a different question class will be +# # instantiated. All these classes derive from the base class `Question`. +# _types = { +# 'radio' : QuestionRadio, +# 'checkbox' : QuestionCheckbox, +# 'text' : QuestionText, +# 'text_regex': QuestionTextRegex, +# 'text_numeric': QuestionTextNumeric, +# 'textarea' : QuestionTextArea, +# # informative panels +# 'information': QuestionInformation, +# 'warning' : QuestionInformation, +# 'alert' : QuestionInformation, +# } + +# # ----------------------------------------------------------------------- +# def __init__(self, questions=None): +# super().__init__() +# if isinstance(questions, dict): +# self.add(questions) +# elif isinstance(questions, str): +# self.load_file(questions) + +# # ----------------------------------------------------------------------- +# # Add single question provided in a dictionary. +# # After this, each question will have at least 'ref' and 'type' keys. +# # ----------------------------------------------------------------------- +# def add(self, question): +# # if ref missing try ref='/path/file.yaml:3' +# try: +# question.setdefault('ref', question['filename'] + ':' + str(question['index'])) +# except KeyError: +# logger.error('Missing "ref". Cannot add question to the pool.') +# return + +# # check duplicate references +# if question['ref'] in self: +# logger.error('Duplicate reference "{0}". Replacing the original one!'.format(question['ref'])) + +# question.setdefault('type', 'information') + +# self[question['ref']] = question +# logger.debug('Added question "{0}" to the pool.'.format(question['ref'])) + +# # ----------------------------------------------------------------------- +# # load single YAML questions file +# # ----------------------------------------------------------------------- +# def load_file(self, filename, questions_dir=''): +# f = path.normpath(path.join(questions_dir, filename)) +# questions = load_yaml(f, default=[]) + +# n = 0 +# for i, q in enumerate(questions): +# if isinstance(q, dict): +# q.update({ +# 'filename': filename, +# 'path': questions_dir, +# 'index': i # position in the file, 0 based +# }) +# self.add(q) # add question +# n += 1 # counter +# else: +# logger.error('Question index {0} from file {1} is not a dictionary. Skipped!'.format(i, filename)) + +# logger.info('Loaded {0} questions from "{1}".'.format(n, filename)) + +# # ----------------------------------------------------------------------- +# # load multiple YAML question files +# # ----------------------------------------------------------------------- +# def load_files(self, files, questions_dir=''): +# for filename in files: +# self.load_file(filename, questions_dir) + +# # ----------------------------------------------------------------------- +# # Given a ref returns an instance of a descendent of Question(), +# # i.e. a question object (radio, checkbox, ...). +# # ----------------------------------------------------------------------- +# def generate(self, ref): + +# # Shallow copy so that script generated questions will not replace +# # the original generators +# q = self[ref].copy() + +# # If question is of generator type, an external program will be run +# # which will print a valid question in yaml format to stdout. This +# # output is then converted to a dictionary and `q` becomes that dict. +# if q['type'] == 'generator': +# logger.debug('Running script to generate question "{0}".'.format(q['ref'])) +# q.setdefault('arg', '') # optional arguments will be sent to stdin +# script = path.normpath(path.join(q['path'], q['script'])) +# out = run_script(script=script, stdin=q['arg']) +# try: +# q.update(out) +# except: +# q.update({ +# 'type': 'alert', +# 'title': 'Erro interno', +# 'text': 'Ocorreu um erro a gerar esta pergunta.' +# }) +# # The generator was replaced by a question but not yet instantiated + +# # Finally we create an instance of Question() +# try: +# qinstance = self._types[q['type']](q) # instance with correct class +# except KeyError as e: +# logger.error('Unknown question type "{0}" in "{1}:{2}".'.format(q['type'], q['filename'], q['ref'])) +# raise e +# except: +# logger.error('Failed to create question "{0}" from file "{1}".'.format(q['ref'], q['filename'])) +# else: +# logger.debug('Generated question "{}".'.format(ref)) +# return qinstance diff --git a/serve.py b/serve.py index caf1bcd..fb4a429 100755 --- a/serve.py +++ b/serve.py @@ -84,11 +84,11 @@ class LoginHandler(BaseHandler): pw = self.get_body_argument('pw') if self.learn.login(uid, pw): - logging.info(f'User "{uid}" login ok.') + # logging.info(f'User "{uid}" login ok.') self.set_secure_cookie("user", str(uid), expires_days=30) self.redirect(self.get_argument("next", "/")) else: - logging.info(f'User "{uid}" login failed.') + # logging.info(f'User "{uid}" login failed.') self.render("login.html", error='Número ou senha incorrectos') # ---------------------------------------------------------------------------- @@ -132,6 +132,7 @@ class QuestionHandler(BaseHandler): # ref = self.get_body_arguments('question_ref') user = self.current_user answer = self.get_body_arguments('answer') + # logger.debug(f'Answer POST from "{user}"') next_question = self.learn.check_answer(user, answer) if next_question is not None: @@ -167,9 +168,10 @@ def main(): # --- start application try: webapp = WebApplication() - except: + except Exception as e: logging.critical('Can\'t start application.') - sys.exit(1) + # sys.exit(1) + raise e # FIXME # --- create webserver http_server = tornado.httpserver.HTTPServer(webapp, ssl_options={ diff --git a/static/sounds/correct.mp3 b/static/sounds/correct.mp3 deleted file mode 100644 index 0ff2a8d..0000000 Binary files a/static/sounds/correct.mp3 and /dev/null differ diff --git a/static/sounds/intro.mp3 b/static/sounds/intro.mp3 deleted file mode 100644 index 660e6ab..0000000 Binary files a/static/sounds/intro.mp3 and /dev/null differ diff --git a/static/sounds/wrong.mp3 b/static/sounds/wrong.mp3 deleted file mode 100644 index 70d79ec..0000000 Binary files a/static/sounds/wrong.mp3 and /dev/null differ -- libgit2 0.21.2