Commit 065611f74a7e84ff9760b271d4f81a9303e8a80b
1 parent
987cf289
Exists in
master
and in
1 other branch
large ammount of changes, main ones:
- allow multiple instances of same question - wrong answer => new instance of question added at the end of topic - show current topic on sidebar - fixed reload crash - fixed text show previous answer if incorrect not yet working.
Showing
9 changed files
with
284 additions
and
247 deletions
Show diff stats
BUGS.md
| 1 | 1 | ||
| 2 | BUGS: | 2 | BUGS: |
| 3 | 3 | ||
| 4 | -- nao permite perguntas repetidas. iterar questions da configuracao em vez das do ficheiro. ver app.py linha 223. | ||
| 5 | -- pymips: activar/desactivar instruções | 4 | +- topicos no sidebar devem ser links para iniciar um topico acessivel. os inacessiveis devem estar inactivos. |
| 6 | - tabs em textarea nao funcionam correctamente (insere 1 espaco em vez de 4) | 5 | - tabs em textarea nao funcionam correctamente (insere 1 espaco em vez de 4) |
| 7 | - reportar comentarios após submeter. | 6 | - reportar comentarios após submeter. |
| 8 | -- logs debug mostrar user | ||
| 9 | -- logs mostrar fim de topico | ||
| 10 | -- textarea, text devem mostrar no html os valores iniciais de ans, se existir | 7 | +- textarea deve mostrar no html os valores iniciais de ans, se existir |
| 11 | - detect questions in questions.yaml without ref -> error ou generate default. | 8 | - detect questions in questions.yaml without ref -> error ou generate default. |
| 12 | - error if demo.yaml has no topics | 9 | - error if demo.yaml has no topics |
| 13 | -- reload da página rebenta o estado. | ||
| 14 | - guardar state cada vez que topico termina | 10 | - guardar state cada vez que topico termina |
| 15 | -- indicar o topico actual no sidebar | ||
| 16 | - session management. close after inactive time. | 11 | - session management. close after inactive time. |
| 17 | - implementar xsrf. Ver [http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection]() | 12 | - implementar xsrf. Ver [http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection]() |
| 18 | - titulos das perguntas não suportam markdown | 13 | - titulos das perguntas não suportam markdown |
| 19 | 14 | ||
| 20 | TODO: | 15 | TODO: |
| 21 | 16 | ||
| 22 | -- implementar http com redirect para https. | ||
| 23 | -- topicos no sidebar devem ser links para iniciar um topico acessivel. os inacessiveis devem estar inactivos. | 17 | +- pymips: activar/desactivar instruções |
| 18 | +- implementar servidor http com redirect para https. | ||
| 24 | - usar codemirror no textarea | 19 | - usar codemirror no textarea |
| 25 | -- mostrar comments quando falha a resposta | ||
| 26 | - generators not working: bcrypt (ver blog) | 20 | - generators not working: bcrypt (ver blog) |
| 27 | 21 | ||
| 28 | FIXED: | 22 | FIXED: |
| 29 | 23 | ||
| 24 | +- logs inicio de topico | ||
| 25 | +- indicar o topico actual no sidebar | ||
| 26 | +- reload da página rebenta o estado. | ||
| 27 | +- text deve mostrar no html os valores iniciais de ans, se existir | ||
| 28 | +- nao permite perguntas repetidas. iterar questions da configuracao em vez das do ficheiro. ver app.py linha 223. | ||
| 30 | - level depender do numero de respostas correctas | 29 | - level depender do numero de respostas correctas |
| 31 | - pymips a funcionar | 30 | - pymips a funcionar |
| 32 | - logs mostram que está a gerar cada pergunta 2 vezes...?? | 31 | - logs mostram que está a gerar cada pergunta 2 vezes...?? |
app.py
| @@ -37,8 +37,8 @@ class LearnApp(object): | @@ -37,8 +37,8 @@ class LearnApp(object): | ||
| 37 | # online students | 37 | # online students |
| 38 | self.online = {} | 38 | self.online = {} |
| 39 | 39 | ||
| 40 | - # build dependency graph | ||
| 41 | - self.build_dependency_graph(conffile) | 40 | + self.depgraph = build_dependency_graph(conffile) |
| 41 | + | ||
| 42 | 42 | ||
| 43 | # connect to database and check registered students | 43 | # connect to database and check registered students |
| 44 | self.db_setup(self.depgraph.graph['database']) | 44 | self.db_setup(self.depgraph.graph['database']) |
| @@ -62,6 +62,7 @@ class LearnApp(object): | @@ -62,6 +62,7 @@ class LearnApp(object): | ||
| 62 | return False # wrong password | 62 | return False # wrong password |
| 63 | 63 | ||
| 64 | # success | 64 | # success |
| 65 | + logger.info(f'User "{uid}" logged in') | ||
| 65 | 66 | ||
| 66 | tt = s.query(StudentTopic).filter(StudentTopic.student_id == uid) | 67 | tt = s.query(StudentTopic).filter(StudentTopic.student_id == uid) |
| 67 | state = {} | 68 | state = {} |
| @@ -76,7 +77,6 @@ class LearnApp(object): | @@ -76,7 +77,6 @@ class LearnApp(object): | ||
| 76 | 'number': student.id, | 77 | 'number': student.id, |
| 77 | 'state': Knowledge(self.depgraph, state=state, student=student.id) | 78 | 'state': Knowledge(self.depgraph, state=state, student=student.id) |
| 78 | } | 79 | } |
| 79 | - logger.info(f'User "{uid}" logged in') | ||
| 80 | return True | 80 | return True |
| 81 | 81 | ||
| 82 | # ------------------------------------------------------------------------ | 82 | # ------------------------------------------------------------------------ |
| @@ -124,115 +124,23 @@ class LearnApp(object): | @@ -124,115 +124,23 @@ class LearnApp(object): | ||
| 124 | return True | 124 | return True |
| 125 | 125 | ||
| 126 | # ------------------------------------------------------------------------ | 126 | # ------------------------------------------------------------------------ |
| 127 | - def get_student_name(self, uid): | ||
| 128 | - return self.online[uid].get('name', '') | ||
| 129 | - | ||
| 130 | - # ------------------------------------------------------------------------ | ||
| 131 | - def get_student_state(self, uid): | ||
| 132 | - return self.online[uid]['state'].get_knowledge_state() | ||
| 133 | - | ||
| 134 | - # ------------------------------------------------------------------------ | ||
| 135 | - def get_student_progress(self, uid): | ||
| 136 | - return self.online[uid]['state'].get_topic_progress() | ||
| 137 | - | ||
| 138 | - # ------------------------------------------------------------------------ | ||
| 139 | - def get_student_question(self, uid): | ||
| 140 | - return self.online[uid]['state'].get_current_question() # dict | ||
| 141 | - | ||
| 142 | - # ------------------------------------------------------------------------ | ||
| 143 | - def get_title(self): | ||
| 144 | - return self.depgraph.graph['title'] | ||
| 145 | - | ||
| 146 | - # ------------------------------------------------------------------------ | ||
| 147 | - def get_topic_name(self, ref): | ||
| 148 | - return self.depgraph.node[ref]['name'] | ||
| 149 | - | ||
| 150 | - # ------------------------------------------------------------------------ | ||
| 151 | - def get_current_public_dir(self, uid): | ||
| 152 | - topic = self.online[uid]['state'].get_current_topic() | ||
| 153 | - p = self.depgraph.graph['path'] | ||
| 154 | - return path.join(p, topic, 'public') | ||
| 155 | - | ||
| 156 | - # ------------------------------------------------------------------------ | ||
| 157 | # check answer and if correct returns new question, otherwise returns None | 127 | # check answer and if correct returns new question, otherwise returns None |
| 158 | # ------------------------------------------------------------------------ | 128 | # ------------------------------------------------------------------------ |
| 159 | def check_answer(self, uid, answer): | 129 | def check_answer(self, uid, answer): |
| 160 | knowledge = self.online[uid]['state'] | 130 | knowledge = self.online[uid]['state'] |
| 161 | - current_question = knowledge.check_answer(answer) | 131 | + q = knowledge.check_answer(answer) |
| 162 | 132 | ||
| 163 | - if current_question is not None: | ||
| 164 | - logger.debug('check_answer: saving answer to db ...') | ||
| 165 | - with self.db_session() as s: | ||
| 166 | - s.add(Answer( | ||
| 167 | - ref=current_question['ref'], | ||
| 168 | - grade=current_question['grade'], | ||
| 169 | - starttime=str(current_question['start_time']), | ||
| 170 | - finishtime=str(current_question['finish_time']), | ||
| 171 | - student_id=uid)) | ||
| 172 | - s.commit() | ||
| 173 | - | ||
| 174 | - return knowledge.new_question() | ||
| 175 | - | ||
| 176 | - # ------------------------------------------------------------------------ | ||
| 177 | - # Given configuration file, loads YAML on that file and builds the graph. | ||
| 178 | - # First, topics such as `computer/mips/exceptions` are added as nodes | ||
| 179 | - # together with dependencies. Then, questions are loaded to a factory. | ||
| 180 | - # ------------------------------------------------------------------------ | ||
| 181 | - def build_dependency_graph(self, config_file): | ||
| 182 | - | ||
| 183 | - # Load configuration file to a dict | ||
| 184 | - try: | ||
| 185 | - with open(config_file, 'r') as f: | ||
| 186 | - config = yaml.load(f) | ||
| 187 | - except FileNotFoundError: | ||
| 188 | - logger.critical(f'File not found: "{config_file}"') | ||
| 189 | - raise LearnAppException | ||
| 190 | - except yaml.scanner.ScannerError as err: | ||
| 191 | - logger.critical(f'Parsing YAML file "{config_file}": {err}') | ||
| 192 | - raise LearnAppException | ||
| 193 | - else: | ||
| 194 | - logger.info(f'Configuration file "{config_file}"') | ||
| 195 | - | ||
| 196 | - # create graph | ||
| 197 | - prefix = config.get('path', '.') | ||
| 198 | - title = config.get('title', '') | ||
| 199 | - database = config.get('database', 'students.db') | ||
| 200 | - g = nx.DiGraph(path=prefix, title=title, database=database) | ||
| 201 | - | ||
| 202 | - # iterate over topics and build graph | ||
| 203 | - topics = config.get('topics', {}) | ||
| 204 | - for ref,attr in topics.items(): | ||
| 205 | - g.add_node(ref) | ||
| 206 | - if isinstance(attr, dict): | ||
| 207 | - g.node[ref]['name'] = attr.get('name', ref) | ||
| 208 | - g.node[ref]['questions'] = attr.get('questions', []) | ||
| 209 | - g.add_edges_from((d,ref) for d in attr.get('deps', [])) | ||
| 210 | - | ||
| 211 | - # iterate over topics and create question factories | ||
| 212 | - logger.info('Loading:') | ||
| 213 | - for ref in g.nodes_iter(): | ||
| 214 | - fullpath = path.expanduser(path.join(prefix, ref)) | ||
| 215 | - filename = path.join(fullpath, 'questions.yaml') | ||
| 216 | - | ||
| 217 | - loaded_questions = load_yaml(filename, default=[]) | ||
| 218 | - | ||
| 219 | - # make dict from list of questions for easier selection | ||
| 220 | - qdict = {q['ref']: q for q in loaded_questions} | ||
| 221 | - | ||
| 222 | - # 'questions' not provided in configuration means load all | ||
| 223 | - if not g.node[ref]['questions']: | ||
| 224 | - g.node[ref]['questions'] = qdict.keys() #[q['ref'] for q in loaded_questions] | ||
| 225 | - | ||
| 226 | - g.node[ref]['factory'] = [] | ||
| 227 | - for qref in g.node[ref]['questions']: | ||
| 228 | - q = qdict[qref] | ||
| 229 | - q['path'] = fullpath | ||
| 230 | - g.node[ref]['factory'].append(QFactory(q)) | 133 | + with self.db_session() as s: |
| 134 | + s.add(Answer( | ||
| 135 | + ref=q['ref'], | ||
| 136 | + grade=q['grade'], | ||
| 137 | + starttime=str(q['start_time']), | ||
| 138 | + finishtime=str(q['finish_time']), | ||
| 139 | + student_id=uid)) | ||
| 140 | + s.commit() | ||
| 231 | 141 | ||
| 232 | - logger.info(f' {len(g.node[ref]["factory"])} questions from "{ref}"') | 142 | + return q['grade'] |
| 233 | 143 | ||
| 234 | - self.depgraph = g | ||
| 235 | - return g | ||
| 236 | 144 | ||
| 237 | # ------------------------------------------------------------------------ | 145 | # ------------------------------------------------------------------------ |
| 238 | # Fill db table 'Topic' with topics from the graph if not already there. | 146 | # Fill db table 'Topic' with topics from the graph if not already there. |
| @@ -274,3 +182,113 @@ class LearnApp(object): | @@ -274,3 +182,113 @@ class LearnApp(object): | ||
| 274 | finally: | 182 | finally: |
| 275 | session.close() | 183 | session.close() |
| 276 | 184 | ||
| 185 | + | ||
| 186 | + | ||
| 187 | + # ======================================================================== | ||
| 188 | + # methods that do not change state (pure functions) | ||
| 189 | + # ======================================================================== | ||
| 190 | + | ||
| 191 | + | ||
| 192 | + # ------------------------------------------------------------------------ | ||
| 193 | + def get_student_name(self, uid): | ||
| 194 | + return self.online[uid].get('name', '') | ||
| 195 | + | ||
| 196 | + # ------------------------------------------------------------------------ | ||
| 197 | + def get_student_state(self, uid): | ||
| 198 | + return self.online[uid]['state'].get_knowledge_state() | ||
| 199 | + | ||
| 200 | + # ------------------------------------------------------------------------ | ||
| 201 | + def get_student_progress(self, uid): | ||
| 202 | + return self.online[uid]['state'].get_topic_progress() | ||
| 203 | + | ||
| 204 | + # ------------------------------------------------------------------------ | ||
| 205 | + def get_student_question(self, uid): | ||
| 206 | + return self.online[uid]['state'].get_current_question() # dict | ||
| 207 | + | ||
| 208 | + # ------------------------------------------------------------------------ | ||
| 209 | + def get_student_topic(self, uid): | ||
| 210 | + return self.online[uid]['state'].get_current_topic() # str | ||
| 211 | + | ||
| 212 | + # ------------------------------------------------------------------------ | ||
| 213 | + def get_title(self): | ||
| 214 | + return self.depgraph.graph['title'] | ||
| 215 | + | ||
| 216 | + # ------------------------------------------------------------------------ | ||
| 217 | + def get_topic_name(self, ref): | ||
| 218 | + return self.depgraph.node[ref]['name'] | ||
| 219 | + | ||
| 220 | + # ------------------------------------------------------------------------ | ||
| 221 | + def get_current_public_dir(self, uid): | ||
| 222 | + topic = self.online[uid]['state'].get_current_topic() | ||
| 223 | + p = self.depgraph.graph['path'] | ||
| 224 | + return path.join(p, topic, 'public') | ||
| 225 | + | ||
| 226 | + | ||
| 227 | + | ||
| 228 | +# ============================================================================ | ||
| 229 | +# Given configuration file, loads YAML on that file and builds a digraph. | ||
| 230 | +# First, topics such as `computer/mips/exceptions` are added as nodes | ||
| 231 | +# together with dependencies. Then, questions are loaded to a factory. | ||
| 232 | +# | ||
| 233 | +# g.graph['path'] base path where topic directories are located | ||
| 234 | +# g.graph['title'] title defined in the configuration YAML | ||
| 235 | +# g.graph['database'] sqlite3 database file to use | ||
| 236 | +# | ||
| 237 | +# Nodes are the topic references e.g. 'my/topic' | ||
| 238 | +# g.node['my/topic']['name'] name of the topic | ||
| 239 | +# g.node['my/topic']['questions'] list of question refs defined in YAML | ||
| 240 | +# g.node['my/topic']['factory'] dict with question factories | ||
| 241 | +# ---------------------------------------------------------------------------- | ||
| 242 | +def build_dependency_graph(config_file): | ||
| 243 | + | ||
| 244 | + # Load configuration file to a dict | ||
| 245 | + try: | ||
| 246 | + with open(config_file, 'r') as f: | ||
| 247 | + config = yaml.load(f) | ||
| 248 | + except FileNotFoundError: | ||
| 249 | + logger.critical(f'File not found: "{config_file}"') | ||
| 250 | + raise LearnAppException | ||
| 251 | + except yaml.scanner.ScannerError as err: | ||
| 252 | + logger.critical(f'Parsing YAML file "{config_file}": {err}') | ||
| 253 | + raise LearnAppException | ||
| 254 | + else: | ||
| 255 | + logger.info(f'Configuration file "{config_file}"') | ||
| 256 | + | ||
| 257 | + # create graph | ||
| 258 | + prefix = config.get('path', '.') | ||
| 259 | + title = config.get('title', '') | ||
| 260 | + database = config.get('database', 'students.db') | ||
| 261 | + g = nx.DiGraph(path=prefix, title=title, database=database) | ||
| 262 | + | ||
| 263 | + # iterate over topics and build graph | ||
| 264 | + topics = config.get('topics', {}) | ||
| 265 | + for ref,attr in topics.items(): | ||
| 266 | + g.add_node(ref) | ||
| 267 | + if isinstance(attr, dict): | ||
| 268 | + g.node[ref]['name'] = attr.get('name', ref) | ||
| 269 | + g.node[ref]['questions'] = attr.get('questions', []) | ||
| 270 | + g.add_edges_from((d,ref) for d in attr.get('deps', [])) | ||
| 271 | + | ||
| 272 | + # iterate over topics and create question factories | ||
| 273 | + logger.info('Loading:') | ||
| 274 | + for tref in g.nodes_iter(): | ||
| 275 | + tnode = g.node[tref] # current node (topic) | ||
| 276 | + fullpath = path.expanduser(path.join(prefix, tref)) | ||
| 277 | + filename = path.join(fullpath, 'questions.yaml') | ||
| 278 | + | ||
| 279 | + loaded_questions = load_yaml(filename, default=[]) | ||
| 280 | + | ||
| 281 | + # if questions not in configuration then load all, preserve order | ||
| 282 | + if not tnode['questions']: | ||
| 283 | + tnode['questions'] = [q['ref'] for q in loaded_questions] | ||
| 284 | + | ||
| 285 | + # make questions factory (without repeting same question) | ||
| 286 | + tnode['factory'] = {} | ||
| 287 | + for q in loaded_questions: | ||
| 288 | + if q['ref'] in tnode['questions']: | ||
| 289 | + q['path'] = fullpath | ||
| 290 | + tnode['factory'][q['ref']] = QFactory(q) | ||
| 291 | + | ||
| 292 | + logger.info(f' {len(tnode["questions"])} questions from "{tref}"') | ||
| 293 | + | ||
| 294 | + return g |
knowledge.py
| @@ -17,41 +17,87 @@ logger = logging.getLogger(__name__) | @@ -17,41 +17,87 @@ logger = logging.getLogger(__name__) | ||
| 17 | # kowledge state of each student....?? | 17 | # kowledge state of each student....?? |
| 18 | # ---------------------------------------------------------------------------- | 18 | # ---------------------------------------------------------------------------- |
| 19 | class Knowledge(object): | 19 | class Knowledge(object): |
| 20 | + # ======================================================================= | ||
| 21 | + # methods that update state | ||
| 22 | + # ======================================================================= | ||
| 23 | + | ||
| 20 | def __init__(self, depgraph, state={}, student=''): | 24 | def __init__(self, depgraph, state={}, student=''): |
| 21 | self.depgraph = depgraph | 25 | self.depgraph = depgraph |
| 22 | self.state = state # {'topic_id': {'level':0.5, 'date': datetime}, ...} | 26 | self.state = state # {'topic_id': {'level':0.5, 'date': datetime}, ...} |
| 23 | self.student = student | 27 | self.student = student |
| 24 | 28 | ||
| 25 | - # compute recommended sequence of topics (FIXME) | 29 | + # compute recommended sequence of topics ['a', 'b',...] |
| 26 | self.topic_sequence = nx.topological_sort(self.depgraph) | 30 | self.topic_sequence = nx.topological_sort(self.depgraph) |
| 27 | 31 | ||
| 28 | - # select a topic to do | ||
| 29 | - self.new_topic() | 32 | + print(self.topic_sequence) |
| 33 | + print(self.depgraph.edges()) | ||
| 34 | + | ||
| 35 | + # select a topic to do and initialize questions | ||
| 36 | + self.start_topic() | ||
| 30 | 37 | ||
| 31 | # ------------------------------------------------------------------------ | 38 | # ------------------------------------------------------------------------ |
| 32 | # Start a new topic. If not provided, selects the first with level < 0.8 | 39 | # Start a new topic. If not provided, selects the first with level < 0.8 |
| 33 | # If all levels > 0.8, will stay in the last one forever... | 40 | # If all levels > 0.8, will stay in the last one forever... |
| 34 | # ------------------------------------------------------------------------ | 41 | # ------------------------------------------------------------------------ |
| 35 | - def new_topic(self, topic=None): | 42 | + def start_topic(self, topic=None): |
| 36 | if topic is None: | 43 | if topic is None: |
| 37 | for topic in self.topic_sequence: | 44 | for topic in self.topic_sequence: |
| 38 | - if topic not in self.state or self.state[topic]['level'] < 0.8: | 45 | + unlocked = topic in self.state |
| 46 | + needs_work = unlocked and self.state[topic]['level'] < 0.8 | ||
| 47 | + factory = self.depgraph.node[topic]['factory'] | ||
| 48 | + if needs_work and factory: | ||
| 39 | break | 49 | break |
| 40 | - | ||
| 41 | - logger.info(f'Student {self.student} new topic "{topic}"') | 50 | + # logger.info(f'{self.student} skipped topic "{topic}"') |
| 51 | + else: | ||
| 52 | + factory = self.depgraph.node[topic]['factory'] | ||
| 53 | + # FIXME if factory is empty??? | ||
| 42 | 54 | ||
| 43 | self.current_topic = topic | 55 | self.current_topic = topic |
| 44 | - # self.current_topic_idx = self.topic_sequence.index(topic) | ||
| 45 | - self.questions = self.generate_questions_for_topic(topic) | ||
| 46 | - self.current_question = None | 56 | + logger.info(f'User "{self.student}" topic set to "{topic}"') |
| 57 | + | ||
| 58 | + # generate question instances for current topic | ||
| 59 | + questionlist = self.depgraph.node[topic]['questions'] | ||
| 60 | + factory = self.depgraph.node[topic]['factory'] | ||
| 61 | + self.questions = [factory[qref].generate() for qref in questionlist] | ||
| 62 | + | ||
| 63 | + self.current_question = self.questions.pop(0) | ||
| 64 | + self.current_question['start_time'] = datetime.now() | ||
| 47 | self.finished_questions = [] | 65 | self.finished_questions = [] |
| 48 | - self.correct_answers = 1 | ||
| 49 | - self.wrong_answers = 0 | ||
| 50 | 66 | ||
| 51 | # ------------------------------------------------------------------------ | 67 | # ------------------------------------------------------------------------ |
| 52 | - def generate_questions_for_topic(self, topic): | ||
| 53 | - factory = self.depgraph.node[topic]['factory'] | ||
| 54 | - return [q.generate() for q in factory] | 68 | + # returns the current question with correction, time and comments updated |
| 69 | + # ------------------------------------------------------------------------ | ||
| 70 | + def check_answer(self, answer): | ||
| 71 | + q = self.current_question | ||
| 72 | + q['finish_time'] = datetime.now() | ||
| 73 | + grade = q.correct(answer) | ||
| 74 | + logger.debug(f'User {self.student}: grade = {grade}') | ||
| 75 | + | ||
| 76 | + # new question if answer is correct | ||
| 77 | + if grade > 0.999: | ||
| 78 | + self.finished_questions.append(q) | ||
| 79 | + try: | ||
| 80 | + self.current_question = self.questions.pop(0) # FIXME empty? | ||
| 81 | + except IndexError: | ||
| 82 | + self.current_question = None | ||
| 83 | + self.state[self.current_topic] = { | ||
| 84 | + 'level': 1.0, | ||
| 85 | + 'date': datetime.now() | ||
| 86 | + } | ||
| 87 | + else: | ||
| 88 | + self.current_question['start_time'] = datetime.now() | ||
| 89 | + else: | ||
| 90 | + # FIXME debug this | ||
| 91 | + factory = self.depgraph.node[self.current_topic]['factory'] | ||
| 92 | + self.questions.append(factory[q['ref']].generate()) | ||
| 93 | + print([q['ref'] for q in self.questions]) | ||
| 94 | + | ||
| 95 | + return q | ||
| 96 | + | ||
| 97 | + | ||
| 98 | + # ======================================================================== | ||
| 99 | + # pure functions of the state (no side effects) | ||
| 100 | + # ======================================================================== | ||
| 55 | 101 | ||
| 56 | # ------------------------------------------------------------------------ | 102 | # ------------------------------------------------------------------------ |
| 57 | def get_current_question(self): | 103 | def get_current_question(self): |
| @@ -62,8 +108,8 @@ class Knowledge(object): | @@ -62,8 +108,8 @@ class Knowledge(object): | ||
| 62 | return self.current_topic | 108 | return self.current_topic |
| 63 | 109 | ||
| 64 | # ------------------------------------------------------------------------ | 110 | # ------------------------------------------------------------------------ |
| 65 | - def get_knowledge_state(self): | ||
| 66 | - ts = [] # FIXME why list?? | 111 | + def get_knowledge_state(self): # [('topic', 0.9), ...] |
| 112 | + ts = [] | ||
| 67 | for t in self.topic_sequence: | 113 | for t in self.topic_sequence: |
| 68 | if t in self.state: | 114 | if t in self.state: |
| 69 | ts.append((t, self.state[t]['level'])) | 115 | ts.append((t, self.state[t]['level'])) |
| @@ -73,44 +119,5 @@ class Knowledge(object): | @@ -73,44 +119,5 @@ class Knowledge(object): | ||
| 73 | 119 | ||
| 74 | # ------------------------------------------------------------------------ | 120 | # ------------------------------------------------------------------------ |
| 75 | def get_topic_progress(self): | 121 | def get_topic_progress(self): |
| 76 | - return len(self.finished_questions) / (len(self.finished_questions) + len(self.questions)) | ||
| 77 | - | ||
| 78 | - # ------------------------------------------------------------------------ | ||
| 79 | - # if answer to current question is correct generates a new question | ||
| 80 | - # otherwise returns None | ||
| 81 | - def new_question(self): | ||
| 82 | - if self.current_question is None or \ | ||
| 83 | - self.current_question.get('grade', 0.0) > 0.9: | ||
| 84 | - | ||
| 85 | - # if no more questions in this topic, go to the next one | ||
| 86 | - # keep going if there are no questions in the next topics | ||
| 87 | - while not self.questions: | ||
| 88 | - self.state[self.current_topic] = { | ||
| 89 | - 'level': self.correct_answers / (self.correct_answers + self.wrong_answers), | ||
| 90 | - 'date': datetime.now() | ||
| 91 | - } | ||
| 92 | - self.new_topic() | ||
| 93 | - | ||
| 94 | - self.current_question = self.questions.pop(0) | ||
| 95 | - self.current_question['start_time'] = datetime.now() | ||
| 96 | - self.finished_questions.append(self.current_question) | ||
| 97 | - | ||
| 98 | - logger.debug(f'Student {self.student}: new_question({self.current_question["ref"]})') | ||
| 99 | - return self.current_question | ||
| 100 | - | ||
| 101 | - # ------------------------------------------------------------------------ | ||
| 102 | - # returns the current question with correction, time and comments updated | ||
| 103 | - # ------------------------------------------------------------------------ | ||
| 104 | - def check_answer(self, answer): | ||
| 105 | - question = self.current_question | ||
| 106 | - if question is not None: | ||
| 107 | - question['finish_time'] = datetime.now() | ||
| 108 | - grade = question.correct(answer) | ||
| 109 | - if grade > 0.9: | ||
| 110 | - self.correct_answers += 1 | ||
| 111 | - else: | ||
| 112 | - self.wrong_answers +=1 | ||
| 113 | - | ||
| 114 | - logger.debug(f'Student {self.student}: check_answer({answer}) = {grade}') | 122 | + return len(self.finished_questions) / (1 + len(self.finished_questions) + len(self.questions)) |
| 115 | 123 | ||
| 116 | - return question |
questions.py
| @@ -416,20 +416,11 @@ class QFactory(object): | @@ -416,20 +416,11 @@ class QFactory(object): | ||
| 416 | # which will print a valid question in yaml format to stdout. This | 416 | # which will print a valid question in yaml format to stdout. This |
| 417 | # output is then yaml parsed into a dictionary `q`. | 417 | # output is then yaml parsed into a dictionary `q`. |
| 418 | if q['type'] == 'generator': | 418 | if q['type'] == 'generator': |
| 419 | - logger.debug(f'Running script "{q["script"]}"...') | 419 | + logger.debug(f' \_ Running script "{q["script"]}"...') |
| 420 | q.setdefault('arg', '') # optional arguments will be sent to stdin | 420 | q.setdefault('arg', '') # optional arguments will be sent to stdin |
| 421 | script = path.join(q['path'], q['script']) | 421 | script = path.join(q['path'], q['script']) |
| 422 | out = run_script(script=script, stdin=q['arg']) | 422 | out = run_script(script=script, stdin=q['arg']) |
| 423 | q.update(out) | 423 | q.update(out) |
| 424 | - # try: | ||
| 425 | - # q.update(out) | ||
| 426 | - # except: | ||
| 427 | - # logger.error(f'Question generator "{q["ref"]}"') | ||
| 428 | - # q.update({ | ||
| 429 | - # 'type': 'alert', | ||
| 430 | - # 'title': 'Erro interno', | ||
| 431 | - # 'text': 'Ocorreu um erro a gerar esta pergunta.' | ||
| 432 | - # }) | ||
| 433 | 424 | ||
| 434 | # Finally we create an instance of Question() | 425 | # Finally we create an instance of Question() |
| 435 | try: | 426 | try: |
serve.py
| @@ -160,70 +160,68 @@ class QuestionHandler(BaseHandler): | @@ -160,70 +160,68 @@ class QuestionHandler(BaseHandler): | ||
| 160 | # 'alert': '', FIXME | 160 | # 'alert': '', FIXME |
| 161 | } | 161 | } |
| 162 | 162 | ||
| 163 | - @tornado.web.authenticated | ||
| 164 | - def get(self): | ||
| 165 | - user = self.current_user | ||
| 166 | - state = self.learn.get_student_state(user) # all topics | 163 | + def new_question(self, user): |
| 164 | + state = self.learn.get_student_state(user) # all topics [('a', 0.1), ...] | ||
| 165 | + current_topic = self.learn.get_student_topic(user) # str | ||
| 167 | progress = self.learn.get_student_progress(user) # float | 166 | progress = self.learn.get_student_progress(user) # float |
| 168 | - question = self.learn.get_current_question(user) | ||
| 169 | - print(question) # FIXME cant get current question | 167 | + question = self.learn.get_student_question(user) # dict? |
| 168 | + | ||
| 169 | + question_html = self.render_string(self.templates[question['type']],question=question, md=md) | ||
| 170 | + topics_html = self.render_string('topics.html', state=state, current_topic=current_topic, gettopicname=self.learn.get_topic_name) | ||
| 171 | + | ||
| 172 | + return { | ||
| 173 | + 'method': 'new_question', | ||
| 174 | + 'params': { | ||
| 175 | + 'question': tornado.escape.to_unicode(question_html), | ||
| 176 | + 'state': tornado.escape.to_unicode(topics_html), | ||
| 177 | + 'progress': progress, | ||
| 178 | + } | ||
| 179 | + } | ||
| 180 | + | ||
| 181 | + def shake(self, user): | ||
| 182 | + progress = self.learn.get_student_progress(user) # in the current topic | ||
| 183 | + return { | ||
| 184 | + 'method': 'shake', | ||
| 185 | + 'params': { | ||
| 186 | + 'progress': progress, | ||
| 187 | + } | ||
| 188 | + } | ||
| 189 | + | ||
| 190 | + def finished_topic(self, user): | ||
| 191 | + state = self.learn.get_student_state(user) # all topics | ||
| 192 | + current_topic = self.learn.get_student_topic(uid) | ||
| 170 | 193 | ||
| 171 | - question_html = self.render_string( | ||
| 172 | - self.templates[question['type']], | ||
| 173 | - question=question, # dictionary with the question | ||
| 174 | - md=md, # function that renders markdown to html | ||
| 175 | - ) | ||
| 176 | topics_html = self.render_string('topics.html', | 194 | topics_html = self.render_string('topics.html', |
| 177 | state=state, | 195 | state=state, |
| 196 | + current_topic=current_topic, | ||
| 178 | topicname=self.learn.get_topic_name, # translate ref to names | 197 | topicname=self.learn.get_topic_name, # translate ref to names |
| 179 | ) | 198 | ) |
| 180 | - print(topics_html) | ||
| 181 | - print(question_html) | ||
| 182 | - self.write({ | ||
| 183 | - 'method': 'new_question', | 199 | + return { |
| 200 | + 'method': 'finished_topic', | ||
| 184 | 'params': { | 201 | 'params': { |
| 185 | - 'question': tornado.escape.to_unicode(question_html), | ||
| 186 | 'state': tornado.escape.to_unicode(topics_html), | 202 | 'state': tornado.escape.to_unicode(topics_html), |
| 187 | - 'progress': progress, | ||
| 188 | } | 203 | } |
| 189 | - }) | 204 | + } |
| 190 | 205 | ||
| 191 | - # posting can change state and return new question | 206 | + @tornado.web.authenticated |
| 207 | + def get(self): | ||
| 208 | + self.write(self.new_question(self.current_user)) | ||
| 209 | + | ||
| 210 | + # handles answer posted | ||
| 192 | @tornado.web.authenticated | 211 | @tornado.web.authenticated |
| 193 | def post(self): | 212 | def post(self): |
| 194 | user = self.current_user | 213 | user = self.current_user |
| 195 | answer = self.get_body_arguments('answer') | 214 | answer = self.get_body_arguments('answer') |
| 196 | - next_question = self.learn.check_answer(user, answer) | ||
| 197 | - state = self.learn.get_student_state(user) # all topics | ||
| 198 | - progress = self.learn.get_student_progress(user) # in the current topic | ||
| 199 | - | ||
| 200 | - if next_question is not None: | ||
| 201 | - question_html = self.render_string( | ||
| 202 | - self.templates[next_question['type']], | ||
| 203 | - question=next_question, # dictionary with the question | ||
| 204 | - md=md, # function that renders markdown to html | ||
| 205 | - ) | ||
| 206 | - topics_html = self.render_string( | ||
| 207 | - 'topics.html', | ||
| 208 | - state=state, | ||
| 209 | - topicname=self.learn.get_topic_name, # function that translates topic references to names | ||
| 210 | - ) | ||
| 211 | - | ||
| 212 | - self.write({ | ||
| 213 | - 'method': 'new_question', | ||
| 214 | - 'params': { | ||
| 215 | - 'question': tornado.escape.to_unicode(question_html), | ||
| 216 | - 'state': tornado.escape.to_unicode(topics_html), | ||
| 217 | - 'progress': progress, | ||
| 218 | - }, | ||
| 219 | - }) | 215 | + grade = self.learn.check_answer(user, answer) |
| 216 | + question = self.learn.get_student_question(user) # same, new or None | ||
| 217 | + | ||
| 218 | + if question is None: | ||
| 219 | + self.write(self.finished_topic(user)) | ||
| 220 | + elif grade > 0.999: | ||
| 221 | + self.write(self.new_question(user)) | ||
| 220 | else: | 222 | else: |
| 221 | - self.write({ | ||
| 222 | - 'method': 'shake', | ||
| 223 | - 'params': { | ||
| 224 | - 'progress': progress, | ||
| 225 | - }, | ||
| 226 | - }) | 223 | + self.write(self.shake(user)) |
| 224 | + | ||
| 227 | 225 | ||
| 228 | # ---------------------------------------------------------------------------- | 226 | # ---------------------------------------------------------------------------- |
| 229 | def main(): | 227 | def main(): |
| @@ -262,15 +260,15 @@ def main(): | @@ -262,15 +260,15 @@ def main(): | ||
| 262 | }) | 260 | }) |
| 263 | http_server.listen(8443) | 261 | http_server.listen(8443) |
| 264 | 262 | ||
| 265 | - # --- start webserver | 263 | + # --- run webserver |
| 266 | logging.info('Webserver running...') | 264 | logging.info('Webserver running...') |
| 265 | + | ||
| 267 | try: | 266 | try: |
| 268 | - tornado.ioloop.IOLoop.current().start() | ||
| 269 | - # running... | 267 | + tornado.ioloop.IOLoop.current().start() # running... |
| 270 | except KeyboardInterrupt: | 268 | except KeyboardInterrupt: |
| 271 | tornado.ioloop.IOLoop.current().stop() | 269 | tornado.ioloop.IOLoop.current().stop() |
| 272 | - finally: | ||
| 273 | - logging.critical('Webserver stopped.') | 270 | + |
| 271 | + logging.critical('Webserver stopped.') | ||
| 274 | 272 | ||
| 275 | # ---------------------------------------------------------------------------- | 273 | # ---------------------------------------------------------------------------- |
| 276 | if __name__ == "__main__": | 274 | if __name__ == "__main__": |
templates/learn.html
| @@ -153,7 +153,7 @@ $.fn.extend({ | @@ -153,7 +153,7 @@ $.fn.extend({ | ||
| 153 | } | 153 | } |
| 154 | }); | 154 | }); |
| 155 | 155 | ||
| 156 | -// Processes the response given by the server after an answer is submitted. | 156 | +// Process the response given by the server |
| 157 | function updateQuestion(response){ | 157 | function updateQuestion(response){ |
| 158 | switch (response["method"]) { | 158 | switch (response["method"]) { |
| 159 | case "new_question": | 159 | case "new_question": |
| @@ -194,12 +194,20 @@ function updateQuestion(response){ | @@ -194,12 +194,20 @@ function updateQuestion(response){ | ||
| 194 | // audio.play(); | 194 | // audio.play(); |
| 195 | $('#question_div').animateCSS('zoomIn'); | 195 | $('#question_div').animateCSS('zoomIn'); |
| 196 | break; | 196 | break; |
| 197 | + | ||
| 197 | case "shake": | 198 | case "shake": |
| 198 | // var audio = new Audio('/static/sounds/wrong.mp3'); | 199 | // var audio = new Audio('/static/sounds/wrong.mp3'); |
| 199 | // audio.play(); | 200 | // audio.play(); |
| 200 | $('#topic_progress').css('width', (100*response["params"]["progress"])+'%').attr('aria-valuenow', 100*response["params"]["progress"]); | 201 | $('#topic_progress').css('width', (100*response["params"]["progress"])+'%').attr('aria-valuenow', 100*response["params"]["progress"]); |
| 201 | $('#question_div').animateCSS('shake'); | 202 | $('#question_div').animateCSS('shake'); |
| 202 | break; | 203 | break; |
| 204 | + | ||
| 205 | + case "finished_topic": | ||
| 206 | + $('#topic_progress').css('width', '100%').attr('aria-valuenow', 100); | ||
| 207 | + $("#topics").html(response["params"]["state"]); | ||
| 208 | + | ||
| 209 | + $("#question_div").html('<img src="/static/trophy.png" alt="trophy" class="img-rounded img-responsive center-block">'); // FIXME size | ||
| 210 | + break; | ||
| 203 | } | 211 | } |
| 204 | } | 212 | } |
| 205 | 213 | ||
| @@ -217,14 +225,12 @@ function postQuestion() { | @@ -217,14 +225,12 @@ function postQuestion() { | ||
| 217 | }); | 225 | }); |
| 218 | } | 226 | } |
| 219 | 227 | ||
| 220 | -// Send answer and receive a response. | ||
| 221 | -// The response can be a new_question or a shake if the answer is wrong. | 228 | +// Get current question |
| 222 | function getQuestion() { | 229 | function getQuestion() { |
| 223 | $.ajax({ | 230 | $.ajax({ |
| 224 | // type: "GET", | 231 | // type: "GET", |
| 225 | url: "/question", | 232 | url: "/question", |
| 226 | // headers: {"X-XSRFToken": token}, | 233 | // headers: {"X-XSRFToken": token}, |
| 227 | - // data: $("#question_form").serialize(), // {'a':10,'b':20}, | ||
| 228 | dataType: "json", // expected from server | 234 | dataType: "json", // expected from server |
| 229 | success: updateQuestion, | 235 | success: updateQuestion, |
| 230 | error: function() {alert("O servidor não responde.");} | 236 | error: function() {alert("O servidor não responde.");} |
templates/question-text.html
| @@ -2,7 +2,11 @@ | @@ -2,7 +2,11 @@ | ||
| 2 | 2 | ||
| 3 | {% block answer %} | 3 | {% block answer %} |
| 4 | <fieldset data-role="controlgroup"> | 4 | <fieldset data-role="controlgroup"> |
| 5 | - <input type="text" class="form-control" id="answer" name="answer" value="{{ question['answer'] or '' }}" autofocus> | 5 | + {% if question['answer'] %} |
| 6 | + <input type="text" class="form-control" id="answer" name="answer" value="{{ question['answer'][0] }}" autofocus> | ||
| 7 | + {% else %} | ||
| 8 | + <input type="text" class="form-control" id="answer" name="answer" value="" autofocus> | ||
| 9 | + {% end %} | ||
| 6 | </fieldset><br /> | 10 | </fieldset><br /> |
| 7 | <input type="hidden" name="question_ref" value="{{ question['ref'] }}"> | 11 | <input type="hidden" name="question_ref" value="{{ question['ref'] }}"> |
| 8 | {% end %} | 12 | {% end %} |
templates/question-textarea.html
| 1 | {% extends "question.html" %} | 1 | {% extends "question.html" %} |
| 2 | 2 | ||
| 3 | {% block answer %} | 3 | {% block answer %} |
| 4 | + | ||
| 4 | <textarea class="form-control" rows="{{ question['lines'] }}" name="answer" autofocus>{{ question['answer'] or '' }}</textarea><br /> | 5 | <textarea class="form-control" rows="{{ question['lines'] }}" name="answer" autofocus>{{ question['answer'] or '' }}</textarea><br /> |
| 5 | <input type="hidden" name="question_ref" value="{{ question['ref'] }}"> | 6 | <input type="hidden" name="question_ref" value="{{ question['ref'] }}"> |
| 7 | + | ||
| 6 | {% end %} | 8 | {% end %} |
templates/topics.html
| @@ -4,10 +4,22 @@ | @@ -4,10 +4,22 @@ | ||
| 4 | 4 | ||
| 5 | <ul class="nav nav-pills nav-stacked"> | 5 | <ul class="nav nav-pills nav-stacked"> |
| 6 | {% for t in state %} | 6 | {% for t in state %} |
| 7 | - <li role="presentation" class="disabled"> <!-- class="active" --> | ||
| 8 | - <a href="#" class="disabled">{{ topicname(t[0]) }}<br> | ||
| 9 | - {{ round(t[1]*5)*'<i class="fa fa-star text-success" aria-hidden="true"></i>' + round(5-t[1]*5)*'<i class="fa fa-star-o" aria-hidden="true"></i>' }} | ||
| 10 | - </a> | ||
| 11 | - </li> | 7 | + |
| 8 | + {% if t[0] == current_topic %} | ||
| 9 | + <li class="active"> <!-- class="active" class="disabled" --> | ||
| 10 | + | ||
| 11 | + <a> {{ gettopicname(t[0]) }}<br> | ||
| 12 | + {{ round(t[1]*5)*'<i class="fa fa-star text-success" aria-hidden="true"></i>' + round(5-t[1]*5)*'<i class="fa fa-star-o" aria-hidden="true"></i>' }} | ||
| 13 | + </a> | ||
| 14 | + | ||
| 15 | + {% else %} | ||
| 16 | + <li> <!-- class="active" class="disabled" --> | ||
| 17 | + | ||
| 18 | + <a href="#"> {{ gettopicname(t[0]) }}<br> | ||
| 19 | + {{ round(t[1]*5)*'<i class="fa fa-star text-success" aria-hidden="true"></i>' + round(5-t[1]*5)*'<i class="fa fa-star-o" aria-hidden="true"></i>' }} | ||
| 20 | + </a> | ||
| 21 | + | ||
| 22 | + {% end %} | ||
| 23 | + </li> | ||
| 12 | {% end %} | 24 | {% end %} |
| 13 | -</ul> | ||
| 14 | \ No newline at end of file | 25 | \ No newline at end of file |
| 26 | +</ul> |