From ab185529f9f3b53d77631bf6835299267ca7c531 Mon Sep 17 00:00:00 2001 From: Miguel BarĂ£o Date: Mon, 18 Mar 2019 22:04:35 +0000 Subject: [PATCH] Changes: - initdb.py sorts users by id - start_topic generates questions in threads - add sanity_check_questions as command line option `--check` --- aprendizations/factory.py | 14 +++++++------- aprendizations/initdb.py | 7 +++---- aprendizations/knowledge.py | 13 +++++++++---- aprendizations/learnapp.py | 43 ++++++++++++++++++++++++++++++------------- aprendizations/questions.py | 55 ++++++++++++++++++++++++++++++++++--------------------- aprendizations/serve.py | 7 ++++++- aprendizations/tools.py | 2 +- 7 files changed, 90 insertions(+), 51 deletions(-) diff --git a/aprendizations/factory.py b/aprendizations/factory.py index cbc3739..0f8bd03 100644 --- a/aprendizations/factory.py +++ b/aprendizations/factory.py @@ -35,7 +35,8 @@ from aprendizations.tools import run_script from aprendizations.questions import (QuestionInformation, QuestionRadio, QuestionCheckbox, QuestionText, QuestionTextRegex, QuestionTextArea, - QuestionNumericInterval) + QuestionNumericInterval, + QuestionException) # setup logger for this module logger = logging.getLogger(__name__) @@ -68,10 +69,6 @@ class QFactory(object): # Given a ref returns an instance of a descendent of Question(), # i.e. a question object (radio, checkbox, ...). # ----------------------------------------------------------------------- - # async def generate_async(self): - # loop = asyncio.get_running_loop() - # return await loop.run_in_executor(None, self.generate) - def generate(self): logger.debug(f'Generating "{self.question["ref"]}"...') # Shallow copy so that script generated questions will not replace @@ -92,8 +89,11 @@ class QFactory(object): # Finally we create an instance of Question() try: qinstance = self._types[q['type']](q) # instance matching class - except KeyError as e: - logger.error(f'Unknown type "{q["type"]}" in "{q["ref"]}"') + except QuestionException as e: + logger.error(e) raise e + except KeyError: + logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') + raise else: return qinstance diff --git a/aprendizations/initdb.py b/aprendizations/initdb.py index 7c18471..9f77e16 100644 --- a/aprendizations/initdb.py +++ b/aprendizations/initdb.py @@ -105,7 +105,6 @@ def insert_students_into_db(session, students): # --- start db session --- session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) for s in students]) - session.commit() except sa.exc.IntegrityError: @@ -116,7 +115,7 @@ def insert_students_into_db(session, students): # =========================================================================== def show_students_in_database(session, verbose=False): try: - users = session.query(Student).order_by(Student.id).all() + users = session.query(Student).all() except Exception: raise else: @@ -125,6 +124,7 @@ def show_students_in_database(session, verbose=False): if n == 0: print(' -- none --') else: + users.sort(key=lambda u: f'{u.id:>12}') # sort by number if verbose: for u in users: print(f'{u.id:>12} {u.name}') @@ -162,8 +162,7 @@ def main(): # --- password hashing if students: print(f'Generating password hashes', end='') - def hash_func(s): return hashpw(s, args.pw) - with ThreadPoolExecutor() as executor: # hashing in parallel + with ThreadPoolExecutor() as executor: executor.map(lambda s: hashpw(s, args.pw), students) print() diff --git a/aprendizations/knowledge.py b/aprendizations/knowledge.py index 43935e4..538cd86 100644 --- a/aprendizations/knowledge.py +++ b/aprendizations/knowledge.py @@ -3,6 +3,7 @@ import random from datetime import datetime import logging +import asyncio # libraries import networkx as nx @@ -73,7 +74,7 @@ class StudentKnowledge(object): async def start_topic(self, topic): logger.debug('StudentKnowledge.start_topic()') if self.current_topic == topic: - logger.debug(' Restarting current topic is not allowed.') + logger.info(' Restarting current topic is not allowed.') return False # do not allow locked topics @@ -95,9 +96,12 @@ class StudentKnowledge(object): logger.debug(f'Questions: {", ".join(questions)}') # generate instances of questions - # gen = lambda qref: self.factory[qref].generate() - self.questions = [self.factory[qref].generate() for qref in questions] - # self.questions = [gen(qref) for qref in questions] + # self.questions = [self.factory[ref].generate() for ref in questions] + loop = asyncio.get_running_loop() + generators = [loop.run_in_executor(None, self.factory[qref].generate) + for qref in questions] + self.questions = await asyncio.gather(*generators) + logger.debug(f'Total: {len(self.questions)} questions') # get first question @@ -117,6 +121,7 @@ class StudentKnowledge(object): 'level': self.correct_answers / (self.correct_answers + self.wrong_answers) } + # self.current_topic = None self.unlock_topics() # ------------------------------------------------------------------------ diff --git a/aprendizations/learnapp.py b/aprendizations/learnapp.py index 2e24338..ed30ce8 100644 --- a/aprendizations/learnapp.py +++ b/aprendizations/learnapp.py @@ -60,7 +60,9 @@ class LearnApp(object): session.close() # ------------------------------------------------------------------------ - def __init__(self, config_files, prefix, db): + # init + # ------------------------------------------------------------------------ + def __init__(self, config_files, prefix, db, check=False): self.db_setup(db) # setup database and check students self.online = dict() # online students @@ -68,9 +70,22 @@ class LearnApp(object): for c in config_files: self.populate_graph(c) - self.build_factory() # for all questions of all topics + self.factory = self.make_factory() # for all questions all topics self.db_add_missing_topics(self.deps.nodes()) + if check: + self.sanity_check_questions() + + # ------------------------------------------------------------------------ + def sanity_check_questions(self): + for qref, q in self.factory.items(): + logger.info(f'Generating {qref}...') + try: + q.generate() + except Exception as e: + logger.error(f'Sanity check failed in "{qref}"') + raise e + # ------------------------------------------------------------------------ # login # ------------------------------------------------------------------------ @@ -196,8 +211,8 @@ class LearnApp(object): student = self.online[uid]['state'] try: await student.start_topic(topic) - except KeyError: - logger.warning(f'User "{uid}" opened nonexistent topic: "{topic}"') + except Exception: + logger.warning(f'User "{uid}" could not start topic "{topic}"') else: logger.info(f'User "{uid}" started topic "{topic}"') @@ -267,7 +282,8 @@ class LearnApp(object): t['name'] = attr.get('name', tref) t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic t['file'] = attr.get('file', default_file) # questions.yaml - t['shuffle_questions'] = attr.get('shuffle_questions', default_shuffle_questions) + t['shuffle_questions'] = attr.get('shuffle_questions', + default_shuffle_questions) t['max_tries'] = attr.get('max_tries', default_maxtries) t['forgetting_factor'] = attr.get('forgetting_factor', default_forgetting_factor) @@ -278,12 +294,16 @@ class LearnApp(object): logger.info(f'Loaded {g.number_of_nodes()} topics') + # ======================================================================== + # methods that do not change state (pure functions) + # ======================================================================== + # ------------------------------------------------------------------------ # Buils dictionary of question factories # ------------------------------------------------------------------------ - def build_factory(self): + def make_factory(self): logger.info('Building questions factory') - self.factory = {} # {'qref': QFactory()} + factory = {} # {'qref': QFactory()} g = self.deps for tref in g.nodes(): t = g.node[tref] @@ -310,15 +330,12 @@ class LearnApp(object): for q in questions: if q['ref'] in t['questions']: - self.factory[q['ref']] = QFactory(q) + factory[q['ref']] = QFactory(q) logger.info(f'{len(t["questions"]):6} {tref}') - logger.info(f'Factory contains {len(self.factory)} questions') - - # ======================================================================== - # methods that do not change state (pure functions) - # ======================================================================== + logger.info(f'Factory contains {len(factory)} questions') + return factory # ------------------------------------------------------------------------ def get_login_counter(self, uid): diff --git a/aprendizations/questions.py b/aprendizations/questions.py index cf6d9c3..c201618 100644 --- a/aprendizations/questions.py +++ b/aprendizations/questions.py @@ -13,6 +13,10 @@ from aprendizations.tools import run_script logger = logging.getLogger(__name__) +class QuestionException(Exception): + pass + + # =========================================================================== # Questions derived from Question are already instantiated and ready to be # presented to students. @@ -63,12 +67,12 @@ class QuestionRadio(Question): ''' # ------------------------------------------------------------------------ + # FIXME marking all options right breaks def __init__(self, q): super().__init__(q) n = len(self['options']) - # set defaults if missing self.set_defaults({ 'text': '', 'correct': 0, @@ -76,32 +80,40 @@ class QuestionRadio(Question): 'discount': True, }) - # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] + # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0] # correctness levels from 0.0 to 1.0 (no discount here!) if isinstance(self['correct'], int): self['correct'] = [1.0 if x == self['correct'] else 0.0 for x in range(n)] + if len(self['correct']) != n: + msg = f'Options and correct mismatch in "{self["ref"]}"' + raise QuestionException(msg) + if self['shuffle']: - # separate right from wrong options - right = [i for i in range(n) if self['correct'][i] == 1] + # lists with indices of right and wrong options + right = [i for i in range(n) if self['correct'][i] >= 1] wrong = [i for i in range(n) if self['correct'][i] < 1] self.set_defaults({'choose': 1+len(wrong)}) - # choose 1 correct option - r = random.choice(right) - options = [self['options'][r]] - correct = [1.0] + # try to choose 1 correct option + if right: + r = random.choice(right) + options = [self['options'][r]] + correct = [self['correct'][r]] + else: + options = [] + correct = [] # choose remaining wrong options - random.shuffle(wrong) - nwrong = self['choose']-1 - options.extend(self['options'][i] for i in wrong[:nwrong]) - correct.extend(self['correct'][i] for i in wrong[:nwrong]) + nwrong = self['choose'] - len(correct) + wrongsample = random.sample(wrong, k=nwrong) + options += [self['options'][i] for i in wrongsample] + correct += [self['correct'][i] for i in wrongsample] # final shuffle of the options - perm = random.sample(range(self['choose']), self['choose']) + perm = random.sample(range(self['choose']), k=self['choose']) self['options'] = [str(options[i]) for i in perm] self['correct'] = [float(correct[i]) for i in perm] @@ -118,8 +130,6 @@ class QuestionRadio(Question): x = (x - x_aver) / (1.0 - x_aver) self['grade'] = x - return self['grade'] - # =========================================================================== class QuestionCheckbox(Question): @@ -150,8 +160,8 @@ class QuestionCheckbox(Question): }) if len(self['correct']) != n: - logger.error(f'Options and correct size mismatch in ' - f'"{self["ref"]}", file "{self["filename"]}".') + msg = f'Options and correct mismatch in "{self["ref"]}"' + raise QuestionException(msg) # if an option is a list of (right, wrong), pick one # FIXME it's possible that all options are chosen wrong @@ -168,9 +178,9 @@ class QuestionCheckbox(Question): # generate random permutation, e.g. [2,1,4,0,3] # and apply to `options` and `correct` if self['shuffle']: - perm = random.sample(range(n), self['choose']) - self['options'] = [options[i] for i in perm] - self['correct'] = [correct[i] for i in perm] + perm = random.sample(range(n), k=self['choose']) + self['options'] = [str(options[i]) for i in perm] + self['correct'] = [float(correct[i]) for i in perm] # ------------------------------------------------------------------------ # can return negative values for wrong answers @@ -338,7 +348,10 @@ class QuestionTextArea(Question): except KeyError: logger.error(f'No grade in "{self["correct"]}".') else: - self['grade'] = float(out) + try: + self['grade'] = float(out) + except (TypeError, ValueError): + logger.error(f'Invalid grade in "{self["correct"]}".') # =========================================================================== diff --git a/aprendizations/serve.py b/aprendizations/serve.py index cd7a7ec..c039339 100644 --- a/aprendizations/serve.py +++ b/aprendizations/serve.py @@ -375,6 +375,11 @@ def parse_cmdline_arguments(): ) argparser.add_argument( + '--check', action='store_true', + help='Sanity check all questions' + ) + + argparser.add_argument( '--debug', action='store_true', help='Enable debug messages' ) @@ -451,7 +456,7 @@ def main(): # --- start application logging.info('Starting App') try: - learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db) + learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db, check=arg.check) except Exception as e: logging.critical('Failed to start application') raise e diff --git a/aprendizations/tools.py b/aprendizations/tools.py index 2d7d727..112b0aa 100644 --- a/aprendizations/tools.py +++ b/aprendizations/tools.py @@ -147,7 +147,7 @@ def load_yaml(filename, default=None): else: with f: try: - default = yaml.safe_load(f) # FIXME check if supports all kinds of questions including regex + default = yaml.safe_load(f) except yaml.YAMLError as e: mark = e.problem_mark logger.error(f'In file "{filename}" near line {mark.line}, ' -- libgit2 0.21.2