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 35 from aprendizations.questions import (QuestionInformation, QuestionRadio,
36 36 QuestionCheckbox, QuestionText,
37 37 QuestionTextRegex, QuestionTextArea,
38   - QuestionNumericInterval)
  38 + QuestionNumericInterval,
  39 + QuestionException)
39 40  
40 41 # setup logger for this module
41 42 logger = logging.getLogger(__name__)
... ... @@ -68,10 +69,6 @@ class QFactory(object):
68 69 # Given a ref returns an instance of a descendent of Question(),
69 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 72 def generate(self):
76 73 logger.debug(f'Generating "{self.question["ref"]}"...')
77 74 # Shallow copy so that script generated questions will not replace
... ... @@ -92,8 +89,11 @@ class QFactory(object):
92 89 # Finally we create an instance of Question()
93 90 try:
94 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 94 raise e
  95 + except KeyError:
  96 + logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
  97 + raise
98 98 else:
99 99 return qinstance
... ...
aprendizations/initdb.py
... ... @@ -105,7 +105,6 @@ def insert_students_into_db(session, students):
105 105 # --- start db session ---
106 106 session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw'])
107 107 for s in students])
108   -
109 108 session.commit()
110 109  
111 110 except sa.exc.IntegrityError:
... ... @@ -116,7 +115,7 @@ def insert_students_into_db(session, students):
116 115 # ===========================================================================
117 116 def show_students_in_database(session, verbose=False):
118 117 try:
119   - users = session.query(Student).order_by(Student.id).all()
  118 + users = session.query(Student).all()
120 119 except Exception:
121 120 raise
122 121 else:
... ... @@ -125,6 +124,7 @@ def show_students_in_database(session, verbose=False):
125 124 if n == 0:
126 125 print(' -- none --')
127 126 else:
  127 + users.sort(key=lambda u: f'{u.id:>12}') # sort by number
128 128 if verbose:
129 129 for u in users:
130 130 print(f'{u.id:>12} {u.name}')
... ... @@ -162,8 +162,7 @@ def main():
162 162 # --- password hashing
163 163 if students:
164 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 166 executor.map(lambda s: hashpw(s, args.pw), students)
168 167 print()
169 168  
... ...
aprendizations/knowledge.py
... ... @@ -3,6 +3,7 @@
3 3 import random
4 4 from datetime import datetime
5 5 import logging
  6 +import asyncio
6 7  
7 8 # libraries
8 9 import networkx as nx
... ... @@ -73,7 +74,7 @@ class StudentKnowledge(object):
73 74 async def start_topic(self, topic):
74 75 logger.debug('StudentKnowledge.start_topic()')
75 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 78 return False
78 79  
79 80 # do not allow locked topics
... ... @@ -95,9 +96,12 @@ class StudentKnowledge(object):
95 96 logger.debug(f'Questions: {", ".join(questions)}')
96 97  
97 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 105 logger.debug(f'Total: {len(self.questions)} questions')
102 106  
103 107 # get first question
... ... @@ -117,6 +121,7 @@ class StudentKnowledge(object):
117 121 'level': self.correct_answers / (self.correct_answers +
118 122 self.wrong_answers)
119 123 }
  124 + # self.current_topic = None
120 125 self.unlock_topics()
121 126  
122 127 # ------------------------------------------------------------------------
... ...
aprendizations/learnapp.py
... ... @@ -60,7 +60,9 @@ class LearnApp(object):
60 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 66 self.db_setup(db) # setup database and check students
65 67 self.online = dict() # online students
66 68  
... ... @@ -68,9 +70,22 @@ class LearnApp(object):
68 70 for c in config_files:
69 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 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 90 # login
76 91 # ------------------------------------------------------------------------
... ... @@ -196,8 +211,8 @@ class LearnApp(object):
196 211 student = self.online[uid]['state']
197 212 try:
198 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 216 else:
202 217 logger.info(f'User "{uid}" started topic "{topic}"')
203 218  
... ... @@ -267,7 +282,8 @@ class LearnApp(object):
267 282 t['name'] = attr.get('name', tref)
268 283 t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic
269 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 287 t['max_tries'] = attr.get('max_tries', default_maxtries)
272 288 t['forgetting_factor'] = attr.get('forgetting_factor',
273 289 default_forgetting_factor)
... ... @@ -278,12 +294,16 @@ class LearnApp(object):
278 294  
279 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 302 # Buils dictionary of question factories
283 303 # ------------------------------------------------------------------------
284   - def build_factory(self):
  304 + def make_factory(self):
285 305 logger.info('Building questions factory')
286   - self.factory = {} # {'qref': QFactory()}
  306 + factory = {} # {'qref': QFactory()}
287 307 g = self.deps
288 308 for tref in g.nodes():
289 309 t = g.node[tref]
... ... @@ -310,15 +330,12 @@ class LearnApp(object):
310 330  
311 331 for q in questions:
312 332 if q['ref'] in t['questions']:
313   - self.factory[q['ref']] = QFactory(q)
  333 + factory[q['ref']] = QFactory(q)
314 334  
315 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 341 def get_login_counter(self, uid):
... ...
aprendizations/questions.py
... ... @@ -13,6 +13,10 @@ from aprendizations.tools import run_script
13 13 logger = logging.getLogger(__name__)
14 14  
15 15  
  16 +class QuestionException(Exception):
  17 + pass
  18 +
  19 +
16 20 # ===========================================================================
17 21 # Questions derived from Question are already instantiated and ready to be
18 22 # presented to students.
... ... @@ -63,12 +67,12 @@ class QuestionRadio(Question):
63 67 '''
64 68  
65 69 # ------------------------------------------------------------------------
  70 + # FIXME marking all options right breaks
66 71 def __init__(self, q):
67 72 super().__init__(q)
68 73  
69 74 n = len(self['options'])
70 75  
71   - # set defaults if missing
72 76 self.set_defaults({
73 77 'text': '',
74 78 'correct': 0,
... ... @@ -76,32 +80,40 @@ class QuestionRadio(Question):
76 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 84 # correctness levels from 0.0 to 1.0 (no discount here!)
81 85 if isinstance(self['correct'], int):
82 86 self['correct'] = [1.0 if x == self['correct'] else 0.0
83 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 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 96 wrong = [i for i in range(n) if self['correct'][i] < 1]
89 97  
90 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 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 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 117 self['options'] = [str(options[i]) for i in perm]
106 118 self['correct'] = [float(correct[i]) for i in perm]
107 119  
... ... @@ -118,8 +130,6 @@ class QuestionRadio(Question):
118 130 x = (x - x_aver) / (1.0 - x_aver)
119 131 self['grade'] = x
120 132  
121   - return self['grade']
122   -
123 133  
124 134 # ===========================================================================
125 135 class QuestionCheckbox(Question):
... ... @@ -150,8 +160,8 @@ class QuestionCheckbox(Question):
150 160 })
151 161  
152 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 166 # if an option is a list of (right, wrong), pick one
157 167 # FIXME it's possible that all options are chosen wrong
... ... @@ -168,9 +178,9 @@ class QuestionCheckbox(Question):
168 178 # generate random permutation, e.g. [2,1,4,0,3]
169 179 # and apply to `options` and `correct`
170 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 186 # can return negative values for wrong answers
... ... @@ -338,7 +348,10 @@ class QuestionTextArea(Question):
338 348 except KeyError:
339 349 logger.error(f'No grade in "{self["correct"]}".')
340 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 375 )
376 376  
377 377 argparser.add_argument(
  378 + '--check', action='store_true',
  379 + help='Sanity check all questions'
  380 + )
  381 +
  382 + argparser.add_argument(
378 383 '--debug', action='store_true',
379 384 help='Enable debug messages'
380 385 )
... ... @@ -451,7 +456,7 @@ def main():
451 456 # --- start application
452 457 logging.info('Starting App')
453 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 460 except Exception as e:
456 461 logging.critical('Failed to start application')
457 462 raise e
... ...
aprendizations/tools.py
... ... @@ -147,7 +147,7 @@ def load_yaml(filename, default=None):
147 147 else:
148 148 with f:
149 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 151 except yaml.YAMLError as e:
152 152 mark = e.problem_mark
153 153 logger.error(f'In file "{filename}" near line {mark.line}, '
... ...