Commit ab185529f9f3b53d77631bf6835299267ca7c531

Authored by Miguel Barão
1 parent 43c0279e
Exists in master and in 1 other branch dev

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
@@ -35,7 +35,8 @@ from aprendizations.tools import run_script @@ -35,7 +35,8 @@ from aprendizations.tools import run_script
35 from aprendizations.questions import (QuestionInformation, QuestionRadio, 35 from aprendizations.questions import (QuestionInformation, QuestionRadio,
36 QuestionCheckbox, QuestionText, 36 QuestionCheckbox, QuestionText,
37 QuestionTextRegex, QuestionTextArea, 37 QuestionTextRegex, QuestionTextArea,
38 - QuestionNumericInterval) 38 + QuestionNumericInterval,
  39 + QuestionException)
39 40
40 # setup logger for this module 41 # setup logger for this module
41 logger = logging.getLogger(__name__) 42 logger = logging.getLogger(__name__)
@@ -68,10 +69,6 @@ class QFactory(object): @@ -68,10 +69,6 @@ class QFactory(object):
68 # Given a ref returns an instance of a descendent of Question(), 69 # Given a ref returns an instance of a descendent of Question(),
69 # i.e. a question object (radio, checkbox, ...). 70 # i.e. a question object (radio, checkbox, ...).
70 # ----------------------------------------------------------------------- 71 # -----------------------------------------------------------------------
71 - # async def generate_async(self):  
72 - # loop = asyncio.get_running_loop()  
73 - # return await loop.run_in_executor(None, self.generate)  
74 -  
75 def generate(self): 72 def generate(self):
76 logger.debug(f'Generating "{self.question["ref"]}"...') 73 logger.debug(f'Generating "{self.question["ref"]}"...')
77 # Shallow copy so that script generated questions will not replace 74 # Shallow copy so that script generated questions will not replace
@@ -92,8 +89,11 @@ class QFactory(object): @@ -92,8 +89,11 @@ class QFactory(object):
92 # Finally we create an instance of Question() 89 # Finally we create an instance of Question()
93 try: 90 try:
94 qinstance = self._types[q['type']](q) # instance matching class 91 qinstance = self._types[q['type']](q) # instance matching class
95 - except KeyError as e:  
96 - logger.error(f'Unknown type "{q["type"]}" in "{q["ref"]}"') 92 + except QuestionException as e:
  93 + logger.error(e)
97 raise e 94 raise e
  95 + except KeyError:
  96 + logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
  97 + raise
98 else: 98 else:
99 return qinstance 99 return qinstance
aprendizations/initdb.py
@@ -105,7 +105,6 @@ def insert_students_into_db(session, students): @@ -105,7 +105,6 @@ def insert_students_into_db(session, students):
105 # --- start db session --- 105 # --- start db session ---
106 session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) 106 session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw'])
107 for s in students]) 107 for s in students])
108 -  
109 session.commit() 108 session.commit()
110 109
111 except sa.exc.IntegrityError: 110 except sa.exc.IntegrityError:
@@ -116,7 +115,7 @@ def insert_students_into_db(session, students): @@ -116,7 +115,7 @@ def insert_students_into_db(session, students):
116 # =========================================================================== 115 # ===========================================================================
117 def show_students_in_database(session, verbose=False): 116 def show_students_in_database(session, verbose=False):
118 try: 117 try:
119 - users = session.query(Student).order_by(Student.id).all() 118 + users = session.query(Student).all()
120 except Exception: 119 except Exception:
121 raise 120 raise
122 else: 121 else:
@@ -125,6 +124,7 @@ def show_students_in_database(session, verbose=False): @@ -125,6 +124,7 @@ def show_students_in_database(session, verbose=False):
125 if n == 0: 124 if n == 0:
126 print(' -- none --') 125 print(' -- none --')
127 else: 126 else:
  127 + users.sort(key=lambda u: f'{u.id:>12}') # sort by number
128 if verbose: 128 if verbose:
129 for u in users: 129 for u in users:
130 print(f'{u.id:>12} {u.name}') 130 print(f'{u.id:>12} {u.name}')
@@ -162,8 +162,7 @@ def main(): @@ -162,8 +162,7 @@ def main():
162 # --- password hashing 162 # --- password hashing
163 if students: 163 if students:
164 print(f'Generating password hashes', end='') 164 print(f'Generating password hashes', end='')
165 - def hash_func(s): return hashpw(s, args.pw)  
166 - with ThreadPoolExecutor() as executor: # hashing in parallel 165 + with ThreadPoolExecutor() as executor:
167 executor.map(lambda s: hashpw(s, args.pw), students) 166 executor.map(lambda s: hashpw(s, args.pw), students)
168 print() 167 print()
169 168
aprendizations/knowledge.py
@@ -3,6 +3,7 @@ @@ -3,6 +3,7 @@
3 import random 3 import random
4 from datetime import datetime 4 from datetime import datetime
5 import logging 5 import logging
  6 +import asyncio
6 7
7 # libraries 8 # libraries
8 import networkx as nx 9 import networkx as nx
@@ -73,7 +74,7 @@ class StudentKnowledge(object): @@ -73,7 +74,7 @@ class StudentKnowledge(object):
73 async def start_topic(self, topic): 74 async def start_topic(self, topic):
74 logger.debug('StudentKnowledge.start_topic()') 75 logger.debug('StudentKnowledge.start_topic()')
75 if self.current_topic == topic: 76 if self.current_topic == topic:
76 - logger.debug(' Restarting current topic is not allowed.') 77 + logger.info(' Restarting current topic is not allowed.')
77 return False 78 return False
78 79
79 # do not allow locked topics 80 # do not allow locked topics
@@ -95,9 +96,12 @@ class StudentKnowledge(object): @@ -95,9 +96,12 @@ class StudentKnowledge(object):
95 logger.debug(f'Questions: {", ".join(questions)}') 96 logger.debug(f'Questions: {", ".join(questions)}')
96 97
97 # generate instances of questions 98 # generate instances of questions
98 - # gen = lambda qref: self.factory[qref].generate()  
99 - self.questions = [self.factory[qref].generate() for qref in questions]  
100 - # self.questions = [gen(qref) for qref in questions] 99 + # self.questions = [self.factory[ref].generate() for ref in questions]
  100 + loop = asyncio.get_running_loop()
  101 + generators = [loop.run_in_executor(None, self.factory[qref].generate)
  102 + for qref in questions]
  103 + self.questions = await asyncio.gather(*generators)
  104 +
101 logger.debug(f'Total: {len(self.questions)} questions') 105 logger.debug(f'Total: {len(self.questions)} questions')
102 106
103 # get first question 107 # get first question
@@ -117,6 +121,7 @@ class StudentKnowledge(object): @@ -117,6 +121,7 @@ class StudentKnowledge(object):
117 'level': self.correct_answers / (self.correct_answers + 121 'level': self.correct_answers / (self.correct_answers +
118 self.wrong_answers) 122 self.wrong_answers)
119 } 123 }
  124 + # self.current_topic = None
120 self.unlock_topics() 125 self.unlock_topics()
121 126
122 # ------------------------------------------------------------------------ 127 # ------------------------------------------------------------------------
aprendizations/learnapp.py
@@ -60,7 +60,9 @@ class LearnApp(object): @@ -60,7 +60,9 @@ class LearnApp(object):
60 session.close() 60 session.close()
61 61
62 # ------------------------------------------------------------------------ 62 # ------------------------------------------------------------------------
63 - def __init__(self, config_files, prefix, db): 63 + # init
  64 + # ------------------------------------------------------------------------
  65 + def __init__(self, config_files, prefix, db, check=False):
64 self.db_setup(db) # setup database and check students 66 self.db_setup(db) # setup database and check students
65 self.online = dict() # online students 67 self.online = dict() # online students
66 68
@@ -68,9 +70,22 @@ class LearnApp(object): @@ -68,9 +70,22 @@ class LearnApp(object):
68 for c in config_files: 70 for c in config_files:
69 self.populate_graph(c) 71 self.populate_graph(c)
70 72
71 - self.build_factory() # for all questions of all topics 73 + self.factory = self.make_factory() # for all questions all topics
72 self.db_add_missing_topics(self.deps.nodes()) 74 self.db_add_missing_topics(self.deps.nodes())
73 75
  76 + if check:
  77 + self.sanity_check_questions()
  78 +
  79 + # ------------------------------------------------------------------------
  80 + def sanity_check_questions(self):
  81 + for qref, q in self.factory.items():
  82 + logger.info(f'Generating {qref}...')
  83 + try:
  84 + q.generate()
  85 + except Exception as e:
  86 + logger.error(f'Sanity check failed in "{qref}"')
  87 + raise e
  88 +
74 # ------------------------------------------------------------------------ 89 # ------------------------------------------------------------------------
75 # login 90 # login
76 # ------------------------------------------------------------------------ 91 # ------------------------------------------------------------------------
@@ -196,8 +211,8 @@ class LearnApp(object): @@ -196,8 +211,8 @@ class LearnApp(object):
196 student = self.online[uid]['state'] 211 student = self.online[uid]['state']
197 try: 212 try:
198 await student.start_topic(topic) 213 await student.start_topic(topic)
199 - except KeyError:  
200 - logger.warning(f'User "{uid}" opened nonexistent topic: "{topic}"') 214 + except Exception:
  215 + logger.warning(f'User "{uid}" could not start topic "{topic}"')
201 else: 216 else:
202 logger.info(f'User "{uid}" started topic "{topic}"') 217 logger.info(f'User "{uid}" started topic "{topic}"')
203 218
@@ -267,7 +282,8 @@ class LearnApp(object): @@ -267,7 +282,8 @@ class LearnApp(object):
267 t['name'] = attr.get('name', tref) 282 t['name'] = attr.get('name', tref)
268 t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic 283 t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic
269 t['file'] = attr.get('file', default_file) # questions.yaml 284 t['file'] = attr.get('file', default_file) # questions.yaml
270 - t['shuffle_questions'] = attr.get('shuffle_questions', default_shuffle_questions) 285 + t['shuffle_questions'] = attr.get('shuffle_questions',
  286 + default_shuffle_questions)
271 t['max_tries'] = attr.get('max_tries', default_maxtries) 287 t['max_tries'] = attr.get('max_tries', default_maxtries)
272 t['forgetting_factor'] = attr.get('forgetting_factor', 288 t['forgetting_factor'] = attr.get('forgetting_factor',
273 default_forgetting_factor) 289 default_forgetting_factor)
@@ -278,12 +294,16 @@ class LearnApp(object): @@ -278,12 +294,16 @@ class LearnApp(object):
278 294
279 logger.info(f'Loaded {g.number_of_nodes()} topics') 295 logger.info(f'Loaded {g.number_of_nodes()} topics')
280 296
  297 + # ========================================================================
  298 + # methods that do not change state (pure functions)
  299 + # ========================================================================
  300 +
281 # ------------------------------------------------------------------------ 301 # ------------------------------------------------------------------------
282 # Buils dictionary of question factories 302 # Buils dictionary of question factories
283 # ------------------------------------------------------------------------ 303 # ------------------------------------------------------------------------
284 - def build_factory(self): 304 + def make_factory(self):
285 logger.info('Building questions factory') 305 logger.info('Building questions factory')
286 - self.factory = {} # {'qref': QFactory()} 306 + factory = {} # {'qref': QFactory()}
287 g = self.deps 307 g = self.deps
288 for tref in g.nodes(): 308 for tref in g.nodes():
289 t = g.node[tref] 309 t = g.node[tref]
@@ -310,15 +330,12 @@ class LearnApp(object): @@ -310,15 +330,12 @@ class LearnApp(object):
310 330
311 for q in questions: 331 for q in questions:
312 if q['ref'] in t['questions']: 332 if q['ref'] in t['questions']:
313 - self.factory[q['ref']] = QFactory(q) 333 + factory[q['ref']] = QFactory(q)
314 334
315 logger.info(f'{len(t["questions"]):6} {tref}') 335 logger.info(f'{len(t["questions"]):6} {tref}')
316 336
317 - logger.info(f'Factory contains {len(self.factory)} questions')  
318 -  
319 - # ========================================================================  
320 - # methods that do not change state (pure functions)  
321 - # ======================================================================== 337 + logger.info(f'Factory contains {len(factory)} questions')
  338 + return factory
322 339
323 # ------------------------------------------------------------------------ 340 # ------------------------------------------------------------------------
324 def get_login_counter(self, uid): 341 def get_login_counter(self, uid):
aprendizations/questions.py
@@ -13,6 +13,10 @@ from aprendizations.tools import run_script @@ -13,6 +13,10 @@ from aprendizations.tools import run_script
13 logger = logging.getLogger(__name__) 13 logger = logging.getLogger(__name__)
14 14
15 15
  16 +class QuestionException(Exception):
  17 + pass
  18 +
  19 +
16 # =========================================================================== 20 # ===========================================================================
17 # Questions derived from Question are already instantiated and ready to be 21 # Questions derived from Question are already instantiated and ready to be
18 # presented to students. 22 # presented to students.
@@ -63,12 +67,12 @@ class QuestionRadio(Question): @@ -63,12 +67,12 @@ class QuestionRadio(Question):
63 ''' 67 '''
64 68
65 # ------------------------------------------------------------------------ 69 # ------------------------------------------------------------------------
  70 + # FIXME marking all options right breaks
66 def __init__(self, q): 71 def __init__(self, q):
67 super().__init__(q) 72 super().__init__(q)
68 73
69 n = len(self['options']) 74 n = len(self['options'])
70 75
71 - # set defaults if missing  
72 self.set_defaults({ 76 self.set_defaults({
73 'text': '', 77 'text': '',
74 'correct': 0, 78 'correct': 0,
@@ -76,32 +80,40 @@ class QuestionRadio(Question): @@ -76,32 +80,40 @@ class QuestionRadio(Question):
76 'discount': True, 80 'discount': True,
77 }) 81 })
78 82
79 - # always convert to list, e.g. correct: 2 --> correct: [0,0,1,0,0] 83 + # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0]
80 # correctness levels from 0.0 to 1.0 (no discount here!) 84 # correctness levels from 0.0 to 1.0 (no discount here!)
81 if isinstance(self['correct'], int): 85 if isinstance(self['correct'], int):
82 self['correct'] = [1.0 if x == self['correct'] else 0.0 86 self['correct'] = [1.0 if x == self['correct'] else 0.0
83 for x in range(n)] 87 for x in range(n)]
84 88
  89 + if len(self['correct']) != n:
  90 + msg = f'Options and correct mismatch in "{self["ref"]}"'
  91 + raise QuestionException(msg)
  92 +
85 if self['shuffle']: 93 if self['shuffle']:
86 - # separate right from wrong options  
87 - right = [i for i in range(n) if self['correct'][i] == 1] 94 + # lists with indices of right and wrong options
  95 + right = [i for i in range(n) if self['correct'][i] >= 1]
88 wrong = [i for i in range(n) if self['correct'][i] < 1] 96 wrong = [i for i in range(n) if self['correct'][i] < 1]
89 97
90 self.set_defaults({'choose': 1+len(wrong)}) 98 self.set_defaults({'choose': 1+len(wrong)})
91 99
92 - # choose 1 correct option  
93 - r = random.choice(right)  
94 - options = [self['options'][r]]  
95 - correct = [1.0] 100 + # try to choose 1 correct option
  101 + if right:
  102 + r = random.choice(right)
  103 + options = [self['options'][r]]
  104 + correct = [self['correct'][r]]
  105 + else:
  106 + options = []
  107 + correct = []
96 108
97 # choose remaining wrong options 109 # choose remaining wrong options
98 - random.shuffle(wrong)  
99 - nwrong = self['choose']-1  
100 - options.extend(self['options'][i] for i in wrong[:nwrong])  
101 - correct.extend(self['correct'][i] for i in wrong[:nwrong]) 110 + nwrong = self['choose'] - len(correct)
  111 + wrongsample = random.sample(wrong, k=nwrong)
  112 + options += [self['options'][i] for i in wrongsample]
  113 + correct += [self['correct'][i] for i in wrongsample]
102 114
103 # final shuffle of the options 115 # final shuffle of the options
104 - perm = random.sample(range(self['choose']), self['choose']) 116 + perm = random.sample(range(self['choose']), k=self['choose'])
105 self['options'] = [str(options[i]) for i in perm] 117 self['options'] = [str(options[i]) for i in perm]
106 self['correct'] = [float(correct[i]) for i in perm] 118 self['correct'] = [float(correct[i]) for i in perm]
107 119
@@ -118,8 +130,6 @@ class QuestionRadio(Question): @@ -118,8 +130,6 @@ class QuestionRadio(Question):
118 x = (x - x_aver) / (1.0 - x_aver) 130 x = (x - x_aver) / (1.0 - x_aver)
119 self['grade'] = x 131 self['grade'] = x
120 132
121 - return self['grade']  
122 -  
123 133
124 # =========================================================================== 134 # ===========================================================================
125 class QuestionCheckbox(Question): 135 class QuestionCheckbox(Question):
@@ -150,8 +160,8 @@ class QuestionCheckbox(Question): @@ -150,8 +160,8 @@ class QuestionCheckbox(Question):
150 }) 160 })
151 161
152 if len(self['correct']) != n: 162 if len(self['correct']) != n:
153 - logger.error(f'Options and correct size mismatch in '  
154 - f'"{self["ref"]}", file "{self["filename"]}".') 163 + msg = f'Options and correct mismatch in "{self["ref"]}"'
  164 + raise QuestionException(msg)
155 165
156 # if an option is a list of (right, wrong), pick one 166 # if an option is a list of (right, wrong), pick one
157 # FIXME it's possible that all options are chosen wrong 167 # FIXME it's possible that all options are chosen wrong
@@ -168,9 +178,9 @@ class QuestionCheckbox(Question): @@ -168,9 +178,9 @@ class QuestionCheckbox(Question):
168 # generate random permutation, e.g. [2,1,4,0,3] 178 # generate random permutation, e.g. [2,1,4,0,3]
169 # and apply to `options` and `correct` 179 # and apply to `options` and `correct`
170 if self['shuffle']: 180 if self['shuffle']:
171 - perm = random.sample(range(n), self['choose'])  
172 - self['options'] = [options[i] for i in perm]  
173 - self['correct'] = [correct[i] for i in perm] 181 + perm = random.sample(range(n), k=self['choose'])
  182 + self['options'] = [str(options[i]) for i in perm]
  183 + self['correct'] = [float(correct[i]) for i in perm]
174 184
175 # ------------------------------------------------------------------------ 185 # ------------------------------------------------------------------------
176 # can return negative values for wrong answers 186 # can return negative values for wrong answers
@@ -338,7 +348,10 @@ class QuestionTextArea(Question): @@ -338,7 +348,10 @@ class QuestionTextArea(Question):
338 except KeyError: 348 except KeyError:
339 logger.error(f'No grade in "{self["correct"]}".') 349 logger.error(f'No grade in "{self["correct"]}".')
340 else: 350 else:
341 - self['grade'] = float(out) 351 + try:
  352 + self['grade'] = float(out)
  353 + except (TypeError, ValueError):
  354 + logger.error(f'Invalid grade in "{self["correct"]}".')
342 355
343 356
344 # =========================================================================== 357 # ===========================================================================
aprendizations/serve.py
@@ -375,6 +375,11 @@ def parse_cmdline_arguments(): @@ -375,6 +375,11 @@ def parse_cmdline_arguments():
375 ) 375 )
376 376
377 argparser.add_argument( 377 argparser.add_argument(
  378 + '--check', action='store_true',
  379 + help='Sanity check all questions'
  380 + )
  381 +
  382 + argparser.add_argument(
378 '--debug', action='store_true', 383 '--debug', action='store_true',
379 help='Enable debug messages' 384 help='Enable debug messages'
380 ) 385 )
@@ -451,7 +456,7 @@ def main(): @@ -451,7 +456,7 @@ def main():
451 # --- start application 456 # --- start application
452 logging.info('Starting App') 457 logging.info('Starting App')
453 try: 458 try:
454 - learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db) 459 + learnapp = LearnApp(arg.conffile, prefix=arg.prefix, db=arg.db, check=arg.check)
455 except Exception as e: 460 except Exception as e:
456 logging.critical('Failed to start application') 461 logging.critical('Failed to start application')
457 raise e 462 raise e
aprendizations/tools.py
@@ -147,7 +147,7 @@ def load_yaml(filename, default=None): @@ -147,7 +147,7 @@ def load_yaml(filename, default=None):
147 else: 147 else:
148 with f: 148 with f:
149 try: 149 try:
150 - default = yaml.safe_load(f) # FIXME check if supports all kinds of questions including regex 150 + default = yaml.safe_load(f)
151 except yaml.YAMLError as e: 151 except yaml.YAMLError as e:
152 mark = e.problem_mark 152 mark = e.problem_mark
153 logger.error(f'In file "{filename}" near line {mark.line}, ' 153 logger.error(f'In file "{filename}" near line {mark.line}, '