Commit d823a4d87455beadabebfd26c488199af065fcea

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

Lots of changes:

- changes checkbox correct list to the same semantics as radio with
  interval values in the interval [0,1]
- adds many sanity checks to questions.
- fix display math centering in radio and checkbox options
- fix check for nonexisting goals
- fix missing database
- fix bug where last question would not show images in the solution
1 1
2 # BUGS 2 # BUGS
3 3
4 -  
5 -Traceback (most recent call last):  
6 - File "/home/mjsb/.local/lib/python3.7/site-packages/tornado/web.py", line 1697, in _execute  
7 - result = method(*self.path_args, **self.path_kwargs)  
8 - File "/home/mjsb/.local/lib/python3.7/site-packages/tornado/web.py", line 3174, in wrapper  
9 - return method(self, *args, **kwargs)  
10 - File "/usr/home/mjsb/Work/Projects/aprendizations/aprendizations/serve.py", line 213, in get  
11 - self.learn.start_course(uid, course)  
12 - File "/usr/home/mjsb/Work/Projects/aprendizations/aprendizations/learnapp.py", line 275, in start_course  
13 - student.start_course(course)  
14 - File "/usr/home/mjsb/Work/Projects/aprendizations/aprendizations/student.py", line 57, in start_course  
15 - self.topic_sequence = self.recommend_topic_sequence(topics)  
16 - File "/usr/home/mjsb/Work/Projects/aprendizations/aprendizations/student.py", line 216, in recommend_topic_sequence  
17 - ts.update(nx.ancestors(G, t))  
18 - File "/home/mjsb/.local/lib/python3.7/site-packages/networkx/algorithms/dag.py", line 92, in ancestors  
19 - raise nx.NetworkXError("The node %s is not in the graph." % source)  
20 -networkx.exception.NetworkXError: The node programming/languages/pseudo-tcg/functions-produtorio is not in the graph.  
21 -  
22 -  
23 -  
24 -- detectar se em courses.yaml falta declarar ficheiro. Por exemplo se houver goals que não estao em lado nenhum.  
25 -- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as imagengs porque o current_topic passa a None antes de carregar no botao continuar. O caminho é prefix+None e dá erro.  
26 -- registar last_seen e remover os antigos de cada vez que houver um login. 4 +- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
  5 +- nao esta a seguir o max_tries definido no ficheiro de dependencias.
27 - initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a) 6 - initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
28 -- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.  
29 - double click submits twice. 7 - double click submits twice.
30 -- nao esta a seguir o max_tries definido no ficheiro de dependencias.  
31 -- impedir que quando students.db não é encontrado, crie um ficheiro vazio.  
32 -- classificacoes so devia mostrar os que ja fizeram alguma coisa  
33 -- QFactory.generate() devia fazer run da gen_async, ou remover.  
34 -- marking all options right in a radio question breaks!  
35 -- opcao --prefix devia afectar a base de dados?  
36 - duplo clicks no botao "responder" dessincroniza as questões, ver debounce em https://stackoverflow.com/questions/20281546/how-to-prevent-calling-of-en-event-handler-twice-on-fast-clicks 8 - duplo clicks no botao "responder" dessincroniza as questões, ver debounce em https://stackoverflow.com/questions/20281546/how-to-prevent-calling-of-en-event-handler-twice-on-fast-clicks
37 -- quando termina topico devia apagar as perguntas todas (se falhar a gerar novo topico, aparecem perguntas do antigo)  
38 -- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)  
39 - devia mostrar timeout para o aluno saber a razao. 9 - devia mostrar timeout para o aluno saber a razao.
40 - permitir configuracao para escolher entre static files locais ou remotos 10 - permitir configuracao para escolher entre static files locais ou remotos
41 -- sqlalchemy.pool.impl.NullPool: Exception during reset or similar  
42 -sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in that same thread.  
43 - templates question-*.html tem input hidden question_ref que não é usado. remover? 11 - templates question-*.html tem input hidden question_ref que não é usado. remover?
44 -- guardar o estado a meio de um nível.  
45 -- safari as vezes envia dois gets no inicio do topico. nesses casos, a segunda pergunta não é actualizada no browser... o topico tem de ser gerado qd se escolhe o topico em main_topics. O get nao deve alterar o estado.  
46 -- click numa opcao checkbox fora da checkbox+label não está a funcionar.  
47 - shift-enter não está a funcionar 12 - shift-enter não está a funcionar
48 -- mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam centradas em toda a coluna mas apenas na largura do parágrafo.  
49 - default prefix should be obtained from each course (yaml conf)? 13 - default prefix should be obtained from each course (yaml conf)?
50 -- tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.  
51 14
52 # TODO 15 # TODO
53 16
  17 +- registar last_seen e remover os antigos de cada vez que houver um login.
54 - indicar qtos topicos faltam (>=50%) para terminar o curso. 18 - indicar qtos topicos faltam (>=50%) para terminar o curso.
55 -- use run_script_async to run run_script using asyncio.run?  
56 -- ao fim de 3 tentativas de login, envial email para aluno com link para definir nova password (com timeout de 5 minutos). 19 +- ao fim de 3 tentativas com password errada, envia email com nova password.
57 - mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias. 20 - mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias.
58 - mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos. 21 - mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos.
59 - botão não sei... 22 - botão não sei...
@@ -65,14 +28,23 @@ sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in @@ -65,14 +28,23 @@ sqlite3.ProgrammingError: SQLite objects created in a thread can only be used in
65 - tabela com perguntas / quantidade de respostas certas/erradas. 28 - tabela com perguntas / quantidade de respostas certas/erradas.
66 - tabela com topicos / quantidade de estrelas. 29 - tabela com topicos / quantidade de estrelas.
67 - pymips: activar/desactivar instruções 30 - pymips: activar/desactivar instruções
68 -- implementar servidor http com redirect para https.  
69 -- ao fim de 3 tentativas com password errada, envia email com nova password.  
70 - titulos das perguntas não suportam markdown. 31 - titulos das perguntas não suportam markdown.
71 - pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta. 32 - pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta.
72 - normalizar com perguntations. 33 - normalizar com perguntations.
73 34
74 # FIXED 35 # FIXED
75 36
  37 +- checkbox devia ter correct no intervalo [0,1] tal como radio. em caso de desconto a correccção faz 2*x-1. isto permite a mesma semantica nos dois tipos de perguntas.
  38 +- marking all options right in a radio question breaks!
  39 +- implementar servidor http com redirect para https.
  40 +- tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.
  41 +- click numa opcao checkbox fora da checkbox+label não está a funcionar.
  42 +- mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam centradas em toda a coluna mas apenas na largura do parágrafo.
  43 +- QFactory.generate() devia fazer run da gen_async, ou remover.
  44 +- classificacoes so devia mostrar os que ja fizeram alguma coisa
  45 +- impedir que quando students.db não é encontrado, crie um ficheiro vazio.
  46 +- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.
  47 +- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as imagengs porque o current_topic passa a None antes de carregar no botao continuar. O caminho é prefix+None e dá erro.
76 - caixas com os cursos não se ajustam bem com ecran estreito. 48 - caixas com os cursos não se ajustam bem com ecran estreito.
77 - obter rankings por curso GET course=course_id 49 - obter rankings por curso GET course=course_id
78 - no curso de linear algebra, as perguntas estao shuffled, mas nao deviam estar... nao esta a obedecer a keyword shuffle. 50 - no curso de linear algebra, as perguntas estao shuffled, mas nao deviam estar... nao esta a obedecer a keyword shuffle.
aprendizations/learnapp.py
@@ -16,10 +16,11 @@ import sqlalchemy as sa @@ -16,10 +16,11 @@ import sqlalchemy as sa
16 16
17 # this project 17 # this project
18 from .models import Student, Answer, Topic, StudentTopic 18 from .models import Student, Answer, Topic, StudentTopic
19 -from .questions import Question, QFactory, QDict 19 +from .questions import Question, QFactory, QDict, QuestionException
20 from .student import StudentState 20 from .student import StudentState
21 from .tools import load_yaml 21 from .tools import load_yaml
22 22
  23 +
23 # setup logger for this module 24 # setup logger for this module
24 logger = logging.getLogger(__name__) 25 logger = logging.getLogger(__name__)
25 26
@@ -68,28 +69,36 @@ class LearnApp(object): @@ -68,28 +69,36 @@ class LearnApp(object):
68 69
69 config: Dict[str, Any] = load_yaml(courses) 70 config: Dict[str, Any] = load_yaml(courses)
70 71
71 - # --- courses dict  
72 - self.courses = config['courses']  
73 - logger.info(f'Courses: {", ".join(self.courses.keys())}')  
74 -  
75 # --- topic dependencies are shared between all courses 72 # --- topic dependencies are shared between all courses
76 self.deps = nx.DiGraph(prefix=prefix) 73 self.deps = nx.DiGraph(prefix=prefix)
77 - logger.info('Populating graph:') 74 + logger.info('Populating topic graph:')
78 75
79 t = config.get('topics', {}) # topics defined directly in courses file 76 t = config.get('topics', {}) # topics defined directly in courses file
80 self.populate_graph(t) 77 self.populate_graph(t)
81 logger.info(f'{len(t):>6} topics in {courses}') 78 logger.info(f'{len(t):>6} topics in {courses}')
82 for f in config.get('topics_from', []): 79 for f in config.get('topics_from', []):
83 c = load_yaml(f) # course configuration 80 c = load_yaml(f) # course configuration
84 - logger.info(f'{len(c["topics"]):>6} topics from {f}') 81 +
  82 + # FIXME set defaults??
  83 + logger.info(f'{len(c["topics"]):>6} topics imported from {f}')
85 self.populate_graph(c) 84 self.populate_graph(c)
86 logger.info(f'Graph has {len(self.deps)} topics') 85 logger.info(f'Graph has {len(self.deps)} topics')
87 86
  87 + # --- courses dict
  88 + self.courses = config['courses']
  89 + logger.info(f'Courses: {", ".join(self.courses.keys())}')
  90 + for c, d in self.courses.items():
  91 + for goal in d['goals']:
  92 + if goal not in self.deps.nodes():
  93 + # logger.error(f'Goal "{goal}" of "{c}"" not in the graph')
  94 + raise LearnException(f'Goal "{goal}" from course "{c}" '
  95 + ' does not exist')
  96 +
88 # --- factory is a dict with question generators for all topics 97 # --- factory is a dict with question generators for all topics
89 self.factory: Dict[str, QFactory] = self.make_factory() 98 self.factory: Dict[str, QFactory] = self.make_factory()
90 99
91 # if graph has topics that are not in the database, add them 100 # if graph has topics that are not in the database, add them
92 - self.db_add_missing_topics(self.deps.nodes()) 101 + self.add_missing_topics(self.deps.nodes())
93 102
94 if check: 103 if check:
95 self.sanity_check_questions() 104 self.sanity_check_questions()
@@ -103,8 +112,8 @@ class LearnApp(object): @@ -103,8 +112,8 @@ class LearnApp(object):
103 logger.debug(f'checking {qref}...') 112 logger.debug(f'checking {qref}...')
104 try: 113 try:
105 q = self.factory[qref].generate() 114 q = self.factory[qref].generate()
106 - except Exception:  
107 - logger.error(f'Failed to generate "{qref}".') 115 + except QuestionException as e:
  116 + logger.error(e)
108 errors += 1 117 errors += 1
109 continue # to next question 118 continue # to next question
110 119
@@ -127,7 +136,7 @@ class LearnApp(object): @@ -127,7 +136,7 @@ class LearnApp(object):
127 continue # to next test 136 continue # to next test
128 137
129 if errors > 0: 138 if errors > 0:
130 - logger.error(f'{errors:>6} errors found.') 139 + logger.error(f'{errors:>6} error(s) found.')
131 raise LearnException('Sanity checks') 140 raise LearnException('Sanity checks')
132 else: 141 else:
133 logger.info(' 0 errors found.') 142 logger.info(' 0 errors found.')
@@ -216,13 +225,13 @@ class LearnApp(object): @@ -216,13 +225,13 @@ class LearnApp(object):
216 return True 225 return True
217 226
218 # ------------------------------------------------------------------------ 227 # ------------------------------------------------------------------------
219 - # checks answer (updating student state) and returns grade. FIXME type of answer 228 + # Checks answer and update database. Returns corrected question.
220 # ------------------------------------------------------------------------ 229 # ------------------------------------------------------------------------
221 - async def check_answer(self, uid: str, answer) -> Tuple[Question, str]: 230 + async def check_answer(self, uid: str, answer) -> Question:
222 student = self.online[uid]['state'] 231 student = self.online[uid]['state']
223 topic = student.get_current_topic() 232 topic = student.get_current_topic()
224 233
225 - q, action = await student.check_answer(answer) # may move questions 234 + q = await student.check_answer(answer)
226 235
227 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') 236 logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
228 237
@@ -237,8 +246,8 @@ class LearnApp(object): @@ -237,8 +246,8 @@ class LearnApp(object):
237 topic_id=topic)) 246 topic_id=topic))
238 logger.debug(f'db insert answer of {q["ref"]}') 247 logger.debug(f'db insert answer of {q["ref"]}')
239 248
  249 + # save topic if finished
240 if student.topic_has_finished(): 250 if student.topic_has_finished():
241 - # finished topic, save into database  
242 logger.info(f'User "{uid}" finished "{topic}"') 251 logger.info(f'User "{uid}" finished "{topic}"')
243 level: float = student.get_topic_level(topic) 252 level: float = student.get_topic_level(topic)
244 date: str = str(student.get_topic_date(topic)) 253 date: str = str(student.get_topic_date(topic))
@@ -264,7 +273,13 @@ class LearnApp(object): @@ -264,7 +273,13 @@ class LearnApp(object):
264 273
265 s.add(a) 274 s.add(a)
266 275
267 - return q, action 276 + return q
  277 +
  278 + # ------------------------------------------------------------------------
  279 + # get the question to show (current or new one)
  280 + # ------------------------------------------------------------------------
  281 + async def get_question(self, uid: str) -> Optional[Question]:
  282 + return await self.online[uid]['state'].get_question()
268 283
269 # ------------------------------------------------------------------------ 284 # ------------------------------------------------------------------------
270 # Start course 285 # Start course
@@ -277,7 +292,7 @@ class LearnApp(object): @@ -277,7 +292,7 @@ class LearnApp(object):
277 logger.warning(f'"{uid}" could not start course "{course}": {e}') 292 logger.warning(f'"{uid}" could not start course "{course}": {e}')
278 raise 293 raise
279 else: 294 else:
280 - logger.info(f'"{uid}" started course "{course}"') 295 + logger.info(f'User "{uid}" started course "{course}"')
281 296
282 # ------------------------------------------------------------------------ 297 # ------------------------------------------------------------------------
283 # Start new topic 298 # Start new topic
@@ -294,7 +309,7 @@ class LearnApp(object): @@ -294,7 +309,7 @@ class LearnApp(object):
294 # ------------------------------------------------------------------------ 309 # ------------------------------------------------------------------------
295 # Fill db table 'Topic' with topics from the graph if not already there. 310 # Fill db table 'Topic' with topics from the graph if not already there.
296 # ------------------------------------------------------------------------ 311 # ------------------------------------------------------------------------
297 - def db_add_missing_topics(self, topics: List[str]) -> None: 312 + def add_missing_topics(self, topics: List[str]) -> None:
298 with self.db_session() as s: 313 with self.db_session() as s:
299 new_topics = [Topic(id=t) for t in topics 314 new_topics = [Topic(id=t) for t in topics
300 if (t,) not in s.query(Topic.id)] 315 if (t,) not in s.query(Topic.id)]
@@ -310,6 +325,10 @@ class LearnApp(object): @@ -310,6 +325,10 @@ class LearnApp(object):
310 def db_setup(self, db: str) -> None: 325 def db_setup(self, db: str) -> None:
311 326
312 logger.info(f'Checking database "{db}":') 327 logger.info(f'Checking database "{db}":')
  328 + if not path.exists(db):
  329 + raise LearnException('Database does not exist. '
  330 + 'Use "initdb-aprendizations" to create')
  331 +
313 engine = sa.create_engine(f'sqlite:///{db}', echo=False) 332 engine = sa.create_engine(f'sqlite:///{db}', echo=False)
314 self.Session = sa.orm.sessionmaker(bind=engine) 333 self.Session = sa.orm.sessionmaker(bind=engine)
315 try: 334 try:
@@ -325,7 +344,7 @@ class LearnApp(object): @@ -325,7 +344,7 @@ class LearnApp(object):
325 logger.info(f'{m:6} topics') 344 logger.info(f'{m:6} topics')
326 logger.info(f'{q:6} answers') 345 logger.info(f'{q:6} answers')
327 346
328 - # ============================================================================ 347 + # ========================================================================
329 # Populates a digraph. 348 # Populates a digraph.
330 # 349 #
331 # Nodes are the topic references e.g. 'my/topic' 350 # Nodes are the topic references e.g. 'my/topic'
@@ -380,7 +399,7 @@ class LearnApp(object): @@ -380,7 +399,7 @@ class LearnApp(object):
380 def make_factory(self) -> Dict[str, QFactory]: 399 def make_factory(self) -> Dict[str, QFactory]:
381 400
382 logger.info('Building questions factory:') 401 logger.info('Building questions factory:')
383 - factory: Dict[str, QFactory] = {} 402 + factory = {}
384 g = self.deps 403 g = self.deps
385 for tref in g.nodes(): 404 for tref in g.nodes():
386 t = g.nodes[tref] 405 t = g.nodes[tref]
@@ -389,6 +408,7 @@ class LearnApp(object): @@ -389,6 +408,7 @@ class LearnApp(object):
389 topicpath: str = path.join(g.graph['prefix'], tref) 408 topicpath: str = path.join(g.graph['prefix'], tref)
390 fullpath: str = path.join(topicpath, t['file']) 409 fullpath: str = path.join(topicpath, t['file'])
391 410
  411 + logger.debug(f' Loading {fullpath}')
392 questions: List[QDict] = load_yaml(fullpath, default=[]) 412 questions: List[QDict] = load_yaml(fullpath, default=[])
393 413
394 # update refs to include topic as prefix. 414 # update refs to include topic as prefix.
@@ -397,7 +417,7 @@ class LearnApp(object): @@ -397,7 +417,7 @@ class LearnApp(object):
397 # within the file 417 # within the file
398 for i, q in enumerate(questions): 418 for i, q in enumerate(questions):
399 qref = q.get('ref', str(i)) # ref or number 419 qref = q.get('ref', str(i)) # ref or number
400 - q['ref'] = tref + ':' + qref 420 + q['ref'] = f'{tref}:{qref}'
401 q['path'] = topicpath 421 q['path'] = topicpath
402 q.setdefault('append_wrong', t['append_wrong']) 422 q.setdefault('append_wrong', t['append_wrong'])
403 423
@@ -410,10 +430,11 @@ class LearnApp(object): @@ -410,10 +430,11 @@ class LearnApp(object):
410 for q in questions: 430 for q in questions:
411 if q['ref'] in t['questions']: 431 if q['ref'] in t['questions']:
412 factory[q['ref']] = QFactory(q) 432 factory[q['ref']] = QFactory(q)
  433 + logger.debug(f' + {q["ref"]}')
413 434
414 - logger.info(f'{len(t["questions"]):6} {tref}') 435 + logger.info(f'{len(t["questions"]):6} questions in {tref}')
415 436
416 - logger.info(f'Factory contains {len(factory)} questions') 437 + logger.info(f'Factory has {len(factory)} questions')
417 return factory 438 return factory
418 439
419 # ------------------------------------------------------------------------ 440 # ------------------------------------------------------------------------
aprendizations/main.py
@@ -9,7 +9,7 @@ import sys @@ -9,7 +9,7 @@ import sys
9 from typing import Any, Dict 9 from typing import Any, Dict
10 10
11 # this project 11 # this project
12 -from .learnapp import LearnApp, DatabaseUnusableError 12 +from .learnapp import LearnApp, DatabaseUnusableError, LearnException
13 from .serve import run_webserver 13 from .serve import run_webserver
14 from .tools import load_yaml 14 from .tools import load_yaml
15 from . import APP_NAME, APP_VERSION 15 from . import APP_NAME, APP_VERSION
@@ -160,7 +160,7 @@ def main(): @@ -160,7 +160,7 @@ def main():
160 else: 160 else:
161 logging.info('SSL certificates loaded') 161 logging.info('SSL certificates loaded')
162 162
163 - # --- start application 163 + # --- start application --------------------------------------------------
164 try: 164 try:
165 learnapp = LearnApp(courses=arg.courses, 165 learnapp = LearnApp(courses=arg.courses,
166 prefix=arg.prefix, 166 prefix=arg.prefix,
@@ -178,13 +178,16 @@ def main(): @@ -178,13 +178,16 @@ def main():
178 '--------------------------------------------------------------', 178 '--------------------------------------------------------------',
179 sep='\n') 179 sep='\n')
180 sys.exit(1) 180 sys.exit(1)
  181 + except LearnException as e:
  182 + logging.critical(e)
  183 + sys.exit(1)
181 except Exception: 184 except Exception:
182 logging.critical('Failed to start backend.') 185 logging.critical('Failed to start backend.')
183 sys.exit(1) 186 sys.exit(1)
184 else: 187 else:
185 - logging.info('Backend started') 188 + logging.info('LearnApp started')
186 189
187 - # --- run webserver forever 190 + # --- run webserver forever ----------------------------------------------
188 run_webserver(app=learnapp, ssl=ssl_ctx, port=arg.port, debug=arg.debug) 191 run_webserver(app=learnapp, ssl=ssl_ctx, port=arg.port, debug=arg.debug)
189 192
190 193
aprendizations/questions.py
@@ -42,7 +42,6 @@ class Question(dict): @@ -42,7 +42,6 @@ class Question(dict):
42 'comments': '', 42 'comments': '',
43 'solution': '', 43 'solution': '',
44 'files': {}, 44 'files': {},
45 - # 'max_tries': 3,  
46 })) 45 }))
47 46
48 def correct(self) -> None: 47 def correct(self) -> None:
@@ -72,7 +71,6 @@ class QuestionRadio(Question): @@ -72,7 +71,6 @@ class QuestionRadio(Question):
72 ''' 71 '''
73 72
74 # ------------------------------------------------------------------------ 73 # ------------------------------------------------------------------------
75 - # FIXME marking all options right breaks  
76 def __init__(self, q: QDict) -> None: 74 def __init__(self, q: QDict) -> None:
77 super().__init__(q) 75 super().__init__(q)
78 76
@@ -86,18 +84,46 @@ class QuestionRadio(Question): @@ -86,18 +84,46 @@ class QuestionRadio(Question):
86 'max_tries': (n + 3) // 4 # 1 try for each 4 options 84 'max_tries': (n + 3) // 4 # 1 try for each 4 options
87 })) 85 }))
88 86
89 - # convert int to list, e.g. correct: 2 --> correct: [0,0,1,0,0]  
90 - # correctness levels from 0.0 to 1.0 (no discount here!) 87 + # check correct bounds and convert int to list,
  88 + # e.g. correct: 2 --> correct: [0,0,1,0,0]
91 if isinstance(self['correct'], int): 89 if isinstance(self['correct'], int):
  90 + if not (0 <= self['correct'] < n):
  91 + msg = (f'Correct option not in range 0..{n-1} in '
  92 + f'"{self["ref"]}"')
  93 + raise QuestionException(msg)
  94 +
92 self['correct'] = [1.0 if x == self['correct'] else 0.0 95 self['correct'] = [1.0 if x == self['correct'] else 0.0
93 for x in range(n)] 96 for x in range(n)]
94 97
95 - if len(self['correct']) != n:  
96 - msg = ('Number of options and correct differ in '  
97 - f'"{self["ref"]}", file "{self["filename"]}".')  
98 - logger.error(msg)  
99 - raise QuestionException(msg)  
100 - 98 + elif isinstance(self['correct'], list):
  99 + # must match number of options
  100 + if len(self['correct']) != n:
  101 + msg = (f'Incompatible sizes: {n} options vs '
  102 + f'{len(self["correct"])} correct in "{self["ref"]}"')
  103 + raise QuestionException(msg)
  104 + # make sure is a list of floats
  105 + try:
  106 + self['correct'] = [float(x) for x in self['correct']]
  107 + except (ValueError, TypeError):
  108 + msg = (f'Correct list must contain numbers [0.0, 1.0] or '
  109 + f'booleans in "{self["ref"]}"')
  110 + raise QuestionException(msg)
  111 +
  112 + # check grade boundaries
  113 + if self['discount'] and not all(0.0 <= x <= 1.0
  114 + for x in self['correct']):
  115 + msg = (f'Correct values must be in the interval [0.0, 1.0] in '
  116 + f'"{self["ref"]}"')
  117 + raise QuestionException(msg)
  118 +
  119 + # at least one correct option
  120 + if all(x < 1.0 for x in self['correct']):
  121 + msg = (f'At least one correct option is required in '
  122 + f'"{self["ref"]}"')
  123 + raise QuestionException(msg)
  124 +
  125 + # If shuffle==false, all options are shown as defined
  126 + # otherwise, select 1 correct and choose a few wrong ones
101 if self['shuffle']: 127 if self['shuffle']:
102 # lists with indices of right and wrong options 128 # lists with indices of right and wrong options
103 right = [i for i in range(n) if self['correct'][i] >= 1] 129 right = [i for i in range(n) if self['correct'][i] >= 1]
@@ -123,7 +149,7 @@ class QuestionRadio(Question): @@ -123,7 +149,7 @@ class QuestionRadio(Question):
123 # final shuffle of the options 149 # final shuffle of the options
124 perm = random.sample(range(self['choose']), k=self['choose']) 150 perm = random.sample(range(self['choose']), k=self['choose'])
125 self['options'] = [str(options[i]) for i in perm] 151 self['options'] = [str(options[i]) for i in perm]
126 - self['correct'] = [float(correct[i]) for i in perm] 152 + self['correct'] = [correct[i] for i in perm]
127 153
128 # ------------------------------------------------------------------------ 154 # ------------------------------------------------------------------------
129 # can assign negative grades for wrong answers 155 # can assign negative grades for wrong answers
@@ -131,10 +157,13 @@ class QuestionRadio(Question): @@ -131,10 +157,13 @@ class QuestionRadio(Question):
131 super().correct() 157 super().correct()
132 158
133 if self['answer'] is not None: 159 if self['answer'] is not None:
134 - x = self['correct'][int(self['answer'])]  
135 - if self['discount']:  
136 - n = len(self['options']) # number of options  
137 - x_aver = sum(self['correct']) / n 160 + x = self['correct'][int(self['answer'])] # get grade of the answer
  161 + n = len(self['options'])
  162 + x_aver = sum(self['correct']) / n # expected value of grade
  163 +
  164 + # note: there are no numerical errors when summing 1.0s so the
  165 + # x_aver can be exactly 1.0 if all options are right
  166 + if self['discount'] and x_aver != 1.0:
138 x = (x - x_aver) / (1.0 - x_aver) 167 x = (x - x_aver) / (1.0 - x_aver)
139 self['grade'] = x 168 self['grade'] = x
140 169
@@ -168,12 +197,44 @@ class QuestionCheckbox(Question): @@ -168,12 +197,44 @@ class QuestionCheckbox(Question):
168 'max_tries': max(1, min(n - 1, 3)) 197 'max_tries': max(1, min(n - 1, 3))
169 })) 198 }))
170 199
  200 + # must be a list of numbers
  201 + if not isinstance(self['correct'], list):
  202 + msg = 'Correct must be a list of numbers or booleans'
  203 + raise QuestionException(msg)
  204 +
  205 + # must match number of options
171 if len(self['correct']) != n: 206 if len(self['correct']) != n:
172 - msg = (f'Options and correct size mismatch in '  
173 - f'"{self["ref"]}", file "{self["filename"]}".')  
174 - logger.error(msg) 207 + msg = (f'Incompatible sizes: {n} options vs '
  208 + f'{len(self["correct"])} correct in "{self["ref"]}"')
  209 + raise QuestionException(msg)
  210 +
  211 + # make sure is a list of floats
  212 + try:
  213 + self['correct'] = [float(x) for x in self['correct']]
  214 + except (ValueError, TypeError):
  215 + msg = (f'Correct list must contain numbers or '
  216 + f'booleans in "{self["ref"]}"')
175 raise QuestionException(msg) 217 raise QuestionException(msg)
176 218
  219 + # check grade boundaries
  220 + if self['discount'] and not all(0.0 <= x <= 1.0
  221 + for x in self['correct']):
  222 +
  223 + msg0 = ('+-------------- BEHAVIOR CHANGE NOTICE --------------+')
  224 + msg1 = ('| Correct values must be in the interval [0.0, 1.0]. |')
  225 + msg2 = ('| I will convert to the new behavior, but you should |')
  226 + msg3 = ('| fix it in the question. |')
  227 + msg4 = ('+----------------------------------------------------+')
  228 + logger.warning(msg0)
  229 + logger.warning(msg1)
  230 + logger.warning(msg2)
  231 + logger.warning(msg3)
  232 + logger.warning(msg4)
  233 + logger.warning(f'please fix "{self["ref"]}"')
  234 +
  235 + # normalize to [0,1]
  236 + self['correct'] = [(x+1)/2 for x in self['correct']]
  237 +
177 # if an option is a list of (right, wrong), pick one 238 # if an option is a list of (right, wrong), pick one
178 options = [] 239 options = []
179 correct = [] 240 correct = []
@@ -182,9 +243,10 @@ class QuestionCheckbox(Question): @@ -182,9 +243,10 @@ class QuestionCheckbox(Question):
182 r = random.randint(0, 1) 243 r = random.randint(0, 1)
183 o = o[r] 244 o = o[r]
184 if r == 1: 245 if r == 1:
185 - c = -c 246 + # c = -c
  247 + c = 1.0 - c
186 options.append(str(o)) 248 options.append(str(o))
187 - correct.append(float(c)) 249 + correct.append(c)
188 250
189 # generate random permutation, e.g. [2,1,4,0,3] 251 # generate random permutation, e.g. [2,1,4,0,3]
190 # and apply to `options` and `correct` 252 # and apply to `options` and `correct`
@@ -202,21 +264,20 @@ class QuestionCheckbox(Question): @@ -202,21 +264,20 @@ class QuestionCheckbox(Question):
202 super().correct() 264 super().correct()
203 265
204 if self['answer'] is not None: 266 if self['answer'] is not None:
205 - sum_abs = sum(abs(p) for p in self['correct'])  
206 - if sum_abs < 1e-6: # case correct [0,...,0] avoid div-by-zero  
207 - self['grade'] = 1.0  
208 - 267 + x = 0.0
  268 + if self['discount']:
  269 + sum_abs = sum(abs(2*p-1) for p in self['correct'])
  270 + for i, p in enumerate(self['correct']):
  271 + x += 2*p-1 if str(i) in self['answer'] else 1-2*p
209 else: 272 else:
210 - x = 0.0  
211 -  
212 - if self['discount']:  
213 - for i, p in enumerate(self['correct']):  
214 - x += p if str(i) in self['answer'] else -p  
215 - else:  
216 - for i, p in enumerate(self['correct']):  
217 - x += p if str(i) in self['answer'] else 0.0 273 + sum_abs = sum(abs(p) for p in self['correct'])
  274 + for i, p in enumerate(self['correct']):
  275 + x += p if str(i) in self['answer'] else 0.0
218 276
  277 + try:
219 self['grade'] = x / sum_abs 278 self['grade'] = x / sum_abs
  279 + except ZeroDivisionError:
  280 + self['grade'] = 1.0 # limit p->0
220 281
221 282
222 # ============================================================================ 283 # ============================================================================
@@ -240,15 +301,21 @@ class QuestionText(Question): @@ -240,15 +301,21 @@ class QuestionText(Question):
240 301
241 # make sure its always a list of possible correct answers 302 # make sure its always a list of possible correct answers
242 if not isinstance(self['correct'], list): 303 if not isinstance(self['correct'], list):
243 - self['correct'] = [self['correct']] 304 + self['correct'] = [str(self['correct'])]
  305 + else:
  306 + # make sure all elements of the list are strings
  307 + self['correct'] = [str(a) for a in self['correct']]
244 308
245 - # make sure all elements of the list are strings  
246 - self['correct'] = [str(a) for a in self['correct']] 309 + for f in self['transform']:
  310 + if f not in ('remove_space', 'trim', 'normalize_space', 'lower',
  311 + 'upper'):
  312 + msg = (f'Unknown transform "{f}" in "{self["ref"]}"')
  313 + raise QuestionException(msg)
247 314
248 - # make sure that the answers are invariant with respect to the filters 315 + # check if answers are invariant with respect to the transforms
249 if any(c != self.transform(c) for c in self['correct']): 316 if any(c != self.transform(c) for c in self['correct']):
250 logger.warning(f'in "{self["ref"]}", correct answers are not ' 317 logger.warning(f'in "{self["ref"]}", correct answers are not '
251 - 'invariant wrt transformations') 318 + 'invariant wrt transformations => never correct')
252 319
253 # ------------------------------------------------------------------------ 320 # ------------------------------------------------------------------------
254 # apply optional filters to the answer 321 # apply optional filters to the answer
@@ -302,8 +369,12 @@ class QuestionTextRegex(Question): @@ -302,8 +369,12 @@ class QuestionTextRegex(Question):
302 if not isinstance(self['correct'], list): 369 if not isinstance(self['correct'], list):
303 self['correct'] = [self['correct']] 370 self['correct'] = [self['correct']]
304 371
305 - # make sure all elements of the list are strings  
306 - self['correct'] = [str(a) for a in self['correct']] 372 + # converts patterns to compiled versions
  373 + try:
  374 + self['correct'] = [re.compile(a) for a in self['correct']]
  375 + except Exception:
  376 + msg = f'Failed to compile regex in "{self["ref"]}"'
  377 + raise QuestionException(msg)
307 378
308 # ------------------------------------------------------------------------ 379 # ------------------------------------------------------------------------
309 def correct(self) -> None: 380 def correct(self) -> None:
@@ -312,12 +383,12 @@ class QuestionTextRegex(Question): @@ -312,12 +383,12 @@ class QuestionTextRegex(Question):
312 self['grade'] = 0.0 383 self['grade'] = 0.0
313 for r in self['correct']: 384 for r in self['correct']:
314 try: 385 try:
315 - if re.match(r, self['answer']): 386 + if r.match(self['answer']):
316 self['grade'] = 1.0 387 self['grade'] = 1.0
317 return 388 return
318 except TypeError: 389 except TypeError:
319 - logger.error(f'While matching regex {self["correct"]} with'  
320 - f' answer "{self["answer"]}".') 390 + logger.error(f'While matching regex {r.pattern} with '
  391 + f'answer "{self["answer"]}".')
321 392
322 393
323 # ============================================================================ 394 # ============================================================================
@@ -339,6 +410,30 @@ class QuestionNumericInterval(Question): @@ -339,6 +410,30 @@ class QuestionNumericInterval(Question):
339 'correct': [1.0, -1.0], # will always return false 410 'correct': [1.0, -1.0], # will always return false
340 })) 411 }))
341 412
  413 + # if only one number n is given, make an interval [n,n]
  414 + if isinstance(self['correct'], (int, float)):
  415 + self['correct'] = [float(self['correct']), float(self['correct'])]
  416 +
  417 + # make sure its a list of two numbers
  418 + elif isinstance(self['correct'], list):
  419 + if len(self['correct']) != 2:
  420 + msg = (f'Numeric interval must be a list with two numbers, in '
  421 + f'{self["ref"]}')
  422 + raise QuestionException(msg)
  423 +
  424 + try:
  425 + self['correct'] = [float(n) for n in self['correct']]
  426 + except Exception:
  427 + msg = (f'Numeric interval must be a list with two numbers, in '
  428 + f'{self["ref"]}')
  429 + raise QuestionException(msg)
  430 +
  431 + # invalid
  432 + else:
  433 + msg = (f'Numeric interval must be a list with two numbers, in '
  434 + f'{self["ref"]}')
  435 + raise QuestionException(msg)
  436 +
342 # ------------------------------------------------------------------------ 437 # ------------------------------------------------------------------------
343 def correct(self) -> None: 438 def correct(self) -> None:
344 super().correct() 439 super().correct()
@@ -520,23 +615,27 @@ class QFactory(object): @@ -520,23 +615,27 @@ class QFactory(object):
520 if q['type'] == 'generator': 615 if q['type'] == 'generator':
521 logger.debug(f' \\_ Running "{q["script"]}".') 616 logger.debug(f' \\_ Running "{q["script"]}".')
522 q.setdefault('args', []) 617 q.setdefault('args', [])
523 - q.setdefault('stdin', '') # FIXME is it really necessary? 618 + q.setdefault('stdin', '')
524 script = path.join(q['path'], q['script']) 619 script = path.join(q['path'], q['script'])
525 out = await run_script_async(script=script, args=q['args'], 620 out = await run_script_async(script=script, args=q['args'],
526 stdin=q['stdin']) 621 stdin=q['stdin'])
527 q.update(out) 622 q.update(out)
528 623
529 - # Finally we create an instance of Question() 624 + # Get class for this question type
530 try: 625 try:
531 - qinstance = self._types[q['type']](QDict(q)) # of matching class  
532 - except QuestionException as e:  
533 - logger.error(e)  
534 - raise e 626 + qclass = self._types[q['type']]
535 except KeyError: 627 except KeyError:
536 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"') 628 logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')
537 raise 629 raise
538 - else:  
539 - return qinstance 630 +
  631 + # Finally create an instance of Question()
  632 + try:
  633 + qinstance = qclass(QDict(q))
  634 + except QuestionException as e:
  635 + # logger.error(e)
  636 + raise e
  637 +
  638 + return qinstance
540 639
541 # ------------------------------------------------------------------------ 640 # ------------------------------------------------------------------------
542 def generate(self) -> Question: 641 def generate(self) -> Question:
aprendizations/serve.py
@@ -19,6 +19,7 @@ from tornado.escape import to_unicode @@ -19,6 +19,7 @@ from tornado.escape import to_unicode
19 from .tools import md_to_html 19 from .tools import md_to_html
20 from . import APP_NAME 20 from . import APP_NAME
21 21
  22 +
22 # setup logger for this module 23 # setup logger for this module
23 logger = logging.getLogger(__name__) 24 logger = logging.getLogger(__name__)
24 25
@@ -294,10 +295,10 @@ class QuestionHandler(BaseHandler): @@ -294,10 +295,10 @@ class QuestionHandler(BaseHandler):
294 295
295 # --- get question to render 296 # --- get question to render
296 @tornado.web.authenticated 297 @tornado.web.authenticated
297 - def get(self):  
298 - logger.debug('[QuestionHandler.get]') 298 + async def get(self):
  299 + logger.debug('[QuestionHandler]')
299 user = self.current_user 300 user = self.current_user
300 - q = self.learn.get_current_question(user) 301 + q = await self.learn.get_question(user)
301 302
302 if q is not None: 303 if q is not None:
303 qhtml = self.render_string(self.templates[q['type']], 304 qhtml = self.render_string(self.templates[q['type']],
@@ -326,15 +327,13 @@ class QuestionHandler(BaseHandler): @@ -326,15 +327,13 @@ class QuestionHandler(BaseHandler):
326 # --- post answer, returns what to do next: shake, new_question, finished 327 # --- post answer, returns what to do next: shake, new_question, finished
327 @tornado.web.authenticated 328 @tornado.web.authenticated
328 async def post(self) -> None: 329 async def post(self) -> None:
329 - logger.debug('[QuestionHandler.post]')  
330 user = self.current_user 330 user = self.current_user
331 answer = self.get_body_arguments('answer') # list 331 answer = self.get_body_arguments('answer') # list
332 - logger.debug(f'user = {user}, answer = {answer}') 332 + qid = self.get_body_arguments('qid')[0]
  333 + logger.debug(f'[QuestionHandler] answer={answer}')
333 334
334 # --- check if browser opened different questions simultaneously 335 # --- check if browser opened different questions simultaneously
335 - answer_qid = self.get_body_arguments('qid')[0]  
336 - current_qid = self.learn.get_current_question_id(user)  
337 - if answer_qid != current_qid: 336 + if qid != self.learn.get_current_question_id(user):
338 logger.info(f'User {user} desynchronized questions') 337 logger.info(f'User {user} desynchronized questions')
339 self.write({ 338 self.write({
340 'method': 'invalid', 339 'method': 'invalid',
@@ -346,7 +345,7 @@ class QuestionHandler(BaseHandler): @@ -346,7 +345,7 @@ class QuestionHandler(BaseHandler):
346 return 345 return
347 346
348 # --- brain hacking ;) 347 # --- brain hacking ;)
349 - await asyncio.sleep(1) 348 + await asyncio.sleep(2)
350 349
351 # --- answers are in a list. fix depending on question type 350 # --- answers are in a list. fix depending on question type
352 qtype = self.learn.get_student_question_type(user) 351 qtype = self.learn.get_student_question_type(user)
@@ -361,11 +360,11 @@ class QuestionHandler(BaseHandler): @@ -361,11 +360,11 @@ class QuestionHandler(BaseHandler):
361 ans = answer 360 ans = answer
362 361
363 # --- check answer (nonblocking) and get corrected question and action 362 # --- check answer (nonblocking) and get corrected question and action
364 - q, action = await self.learn.check_answer(user, ans) 363 + q = await self.learn.check_answer(user, ans)
365 364
366 # --- built response to return 365 # --- built response to return
367 - response = {'method': action, 'params': {}}  
368 - if action == 'right': # get next question in the topic 366 + response = {'method': q['status'], 'params': {}}
  367 + if q['status'] == 'right': # get next question in the topic
369 comments_html = self.render_string( 368 comments_html = self.render_string(
370 'comments-right.html', comments=q['comments'], md=md_to_html) 369 'comments-right.html', comments=q['comments'], md=md_to_html)
371 370
@@ -379,7 +378,7 @@ class QuestionHandler(BaseHandler): @@ -379,7 +378,7 @@ class QuestionHandler(BaseHandler):
379 'solution': to_unicode(solution_html), 378 'solution': to_unicode(solution_html),
380 'tries': q['tries'], 379 'tries': q['tries'],
381 } 380 }
382 - elif action == 'try_again': 381 + elif q['status'] == 'try_again':
383 comments_html = self.render_string( 382 comments_html = self.render_string(
384 'comments.html', comments=q['comments'], md=md_to_html) 383 'comments.html', comments=q['comments'], md=md_to_html)
385 384
@@ -389,7 +388,7 @@ class QuestionHandler(BaseHandler): @@ -389,7 +388,7 @@ class QuestionHandler(BaseHandler):
389 'comments': to_unicode(comments_html), 388 'comments': to_unicode(comments_html),
390 'tries': q['tries'], 389 'tries': q['tries'],
391 } 390 }
392 - elif action == 'wrong': # no more tries 391 + elif q['status'] == 'wrong': # no more tries
393 comments_html = self.render_string( 392 comments_html = self.render_string(
394 'comments.html', comments=q['comments'], md=md_to_html) 393 'comments.html', comments=q['comments'], md=md_to_html)
395 394
@@ -404,7 +403,7 @@ class QuestionHandler(BaseHandler): @@ -404,7 +403,7 @@ class QuestionHandler(BaseHandler):
404 'tries': q['tries'], 403 'tries': q['tries'],
405 } 404 }
406 else: 405 else:
407 - logger.error(f'Unknown action: {action}') 406 + logger.error(f'Unknown question status: {q["status"]}')
408 407
409 self.write(response) 408 self.write(response)
410 409
@@ -457,7 +456,7 @@ def run_webserver(app, @@ -457,7 +456,7 @@ def run_webserver(app,
457 456
458 # --- run webserver 457 # --- run webserver
459 signal.signal(signal.SIGINT, signal_handler) 458 signal.signal(signal.SIGINT, signal_handler)
460 - logger.info('Webserver running. (Ctrl-C to stop)') 459 + logger.info('Webserver running... (Ctrl-C to stop)')
461 460
462 try: 461 try:
463 tornado.ioloop.IOLoop.current().start() # running... 462 tornado.ioloop.IOLoop.current().start() # running...
aprendizations/static/css/topic.css
@@ -27,3 +27,6 @@ html { @@ -27,3 +27,6 @@ html {
27 border: 1px solid #eee; 27 border: 1px solid #eee;
28 height: auto; 28 height: auto;
29 } 29 }
  30 +label {
  31 + display: block;
  32 +}
aprendizations/student.py
@@ -74,11 +74,11 @@ class StudentState(object): @@ -74,11 +74,11 @@ class StudentState(object):
74 logger.debug(f'is locked "{topic}"') 74 logger.debug(f'is locked "{topic}"')
75 return 75 return
76 76
77 - # starting new topic 77 + # choose k questions
78 self.current_topic = topic 78 self.current_topic = topic
  79 + # self.current_question = None
79 self.correct_answers = 0 80 self.correct_answers = 0
80 self.wrong_answers = 0 81 self.wrong_answers = 0
81 -  
82 t = self.deps.nodes[topic] 82 t = self.deps.nodes[topic]
83 k = t['choose'] 83 k = t['choose']
84 if t['shuffle_questions']: 84 if t['shuffle_questions']:
@@ -90,8 +90,7 @@ class StudentState(object): @@ -90,8 +90,7 @@ class StudentState(object):
90 self.questions: List[Question] = [await self.factory[ref].gen_async() 90 self.questions: List[Question] = [await self.factory[ref].gen_async()
91 for ref in questions] 91 for ref in questions]
92 92
93 - n = len(self.questions)  
94 - logger.debug(f'generated {n} questions') 93 + logger.debug(f'generated {len(self.questions)} questions')
95 94
96 # get first question 95 # get first question
97 self.next_question() 96 self.next_question()
@@ -110,62 +109,70 @@ class StudentState(object): @@ -110,62 +109,70 @@ class StudentState(object):
110 self.wrong_answers) 109 self.wrong_answers)
111 } 110 }
112 self.current_topic = None 111 self.current_topic = None
  112 + self.current_question = None
113 self.unlock_topics() 113 self.unlock_topics()
114 114
115 # ------------------------------------------------------------------------ 115 # ------------------------------------------------------------------------
116 - # corrects current question with provided answer.  
117 - # implements the logic:  
118 - # - if answer ok, goes to next question  
119 - # - if wrong, counts number of tries. If exceeded, moves on. 116 + # corrects current question
120 # ------------------------------------------------------------------------ 117 # ------------------------------------------------------------------------
121 async def check_answer(self, answer) -> Tuple[Question, str]: 118 async def check_answer(self, answer) -> Tuple[Question, str]:
122 - q: Question = self.current_question 119 + q = self.current_question
123 q['answer'] = answer 120 q['answer'] = answer
124 q['finish_time'] = datetime.now() 121 q['finish_time'] = datetime.now()
125 - logger.debug(f'checking answer of {q["ref"]}...')  
126 await q.correct_async() 122 await q.correct_async()
127 - logger.debug(f'grade = {q["grade"]:.2}')  
128 123
129 if q['grade'] > 0.999: 124 if q['grade'] > 0.999:
130 self.correct_answers += 1 125 self.correct_answers += 1
131 - self.next_question()  
132 - action = 'right' 126 + q['status'] = 'right'
133 127
134 else: 128 else:
135 self.wrong_answers += 1 129 self.wrong_answers += 1
136 - self.current_question['tries'] -= 1  
137 -  
138 - if self.current_question['tries'] > 0:  
139 - action = 'try_again' 130 + q['tries'] -= 1
  131 + if q['tries'] > 0:
  132 + q['status'] = 'try_again'
140 else: 133 else:
141 - action = 'wrong'  
142 - if self.current_question['append_wrong']:  
143 - logger.debug('wrong answer, append new question')  
144 - # self.questions.append(self.factory[q['ref']].generate())  
145 - new_question = await self.factory[q['ref']].gen_async()  
146 - self.questions.append(new_question)  
147 - self.next_question() 134 + q['status'] = 'wrong'
148 135
149 - # returns corrected question (not new one) which might include comments  
150 - return q, action 136 + logger.debug(f'ref = {q["ref"]}, status = {q["status"]}')
  137 + return q
151 138
152 # ------------------------------------------------------------------------ 139 # ------------------------------------------------------------------------
153 - # Move to next question, or None 140 + # get question to show, current or next
154 # ------------------------------------------------------------------------ 141 # ------------------------------------------------------------------------
155 - def next_question(self) -> Optional[Question]: 142 + async def get_question(self) -> Optional[Question]:
  143 + q = self.current_question
  144 + logger.debug(f'{q["ref"]} status = {q["status"]}')
  145 +
  146 + if q['status'] == 'right':
  147 + self.next_question()
  148 + elif q['status'] == 'wrong':
  149 + if q['append_wrong']:
  150 + logger.debug(' wrong answer => append new question')
  151 + new_question = await self.factory[q['ref']].gen_async()
  152 + self.questions.append(new_question)
  153 + self.next_question()
  154 + # elif q['status'] == 'new':
  155 + # pass
  156 + # elif q['status'] == 'try_again':
  157 + # pass
  158 +
  159 + return self.current_question
  160 +
  161 + # ------------------------------------------------------------------------
  162 + # moves to next question
  163 + # ------------------------------------------------------------------------
  164 + def next_question(self) -> None:
156 try: 165 try:
157 - self.current_question = self.questions.pop(0) 166 + q = self.questions.pop(0)
158 except IndexError: 167 except IndexError:
159 - self.current_question = None  
160 self.finish_topic() 168 self.finish_topic()
161 - else:  
162 - self.current_question['start_time'] = datetime.now()  
163 - default_maxtries = self.deps.nodes[self.current_topic]['max_tries']  
164 - maxtries = self.current_question.get('max_tries', default_maxtries)  
165 - self.current_question['tries'] = maxtries  
166 - logger.debug(f'current_question = {self.current_question["ref"]}') 169 + return
167 170
168 - return self.current_question # question or None 171 + t = self.deps.nodes[self.current_topic]
  172 + q['start_time'] = datetime.now()
  173 + q['tries'] = q.get('max_tries', t['max_tries'])
  174 + q['status'] = 'new'
  175 + self.current_question = q
169 176
170 # ------------------------------------------------------------------------ 177 # ------------------------------------------------------------------------
171 # Update proficiency level of the topics using a forgetting factor 178 # Update proficiency level of the topics using a forgetting factor
@@ -178,7 +185,7 @@ class StudentState(object): @@ -178,7 +185,7 @@ class StudentState(object):
178 forgetting_factor = self.deps.nodes[tref]['forgetting_factor'] 185 forgetting_factor = self.deps.nodes[tref]['forgetting_factor']
179 s['level'] *= forgetting_factor ** dt.days # forgetting factor 186 s['level'] *= forgetting_factor ** dt.days # forgetting factor
180 except KeyError: 187 except KeyError:
181 - logger.warning(f'Topic {tref} is not on the graph!') 188 + logger.warning(f'Update topic levels: {tref} not in the graph')
182 189
183 # ------------------------------------------------------------------------ 190 # ------------------------------------------------------------------------
184 # Unlock topics whose dependencies are satisfied (> min_level) 191 # Unlock topics whose dependencies are satisfied (> min_level)
aprendizations/templates/question-checkbox.html
@@ -9,7 +9,9 @@ @@ -9,7 +9,9 @@
9 <div class="custom-control custom-checkbox"> 9 <div class="custom-control custom-checkbox">
10 <input type="checkbox" class="custom-control-input" 10 <input type="checkbox" class="custom-control-input"
11 id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}"> 11 id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
12 - <label for="{{ n }}" class="custom-control-label">{{ md(opt, strip_p_tag=True) }}</label> 12 + <label for="{{ n }}" class="custom-control-label">
  13 + {{ md(opt, strip_p_tag=True) }}
  14 + </label>
13 </div> 15 </div>
14 </a> 16 </a>
15 {% end %} 17 {% end %}
aprendizations/templates/question-radio.html
@@ -9,7 +9,9 @@ @@ -9,7 +9,9 @@
9 <div class="custom-control custom-radio"> 9 <div class="custom-control custom-radio">
10 <input type="radio" class="custom-control-input" 10 <input type="radio" class="custom-control-input"
11 id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}"> 11 id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
12 - <label for="{{ n }}" class="custom-control-label">{{ md(opt, strip_p_tag=True) }}</label> 12 + <label for="{{ n }}" class="custom-control-label">
  13 + {{ md(opt, strip_p_tag=True) }}
  14 + </label>
13 </div> 15 </div>
14 </a> 16 </a>
15 {% end %} 17 {% end %}
config/logger-debug.yaml
@@ -2,50 +2,52 @@ @@ -2,50 +2,52 @@
2 version: 1 2 version: 1
3 3
4 formatters: 4 formatters:
5 - void:  
6 - format: ''  
7 - standard:  
8 - format: '%(asctime)s | %(thread)-15d | %(levelname)-8s | %(module)-10s | %(funcName)-20s | %(message)s'  
9 - # datefmt: '%H:%M:%S' 5 + void:
  6 + format: ''
  7 + standard:
  8 + format: '%(asctime)s | %(levelname)-8s | %(module)-10s | %(funcName)-22s |
  9 + %(message)s'
  10 + # | %(thread)-15d
  11 + # datefmt: '%H:%M:%S'
10 12
11 handlers: 13 handlers:
12 - default:  
13 - level: 'DEBUG'  
14 - class: 'logging.StreamHandler'  
15 - formatter: 'standard'  
16 - stream: 'ext://sys.stdout' 14 + default:
  15 + level: 'DEBUG'
  16 + class: 'logging.StreamHandler'
  17 + formatter: 'standard'
  18 + stream: 'ext://sys.stdout'
17 19
18 loggers: 20 loggers:
19 - '':  
20 - handlers: ['default']  
21 - level: 'DEBUG'  
22 -  
23 - 'aprendizations.factory':  
24 - handlers: ['default']  
25 - level: 'DEBUG'  
26 - propagate: false  
27 -  
28 - 'aprendizations.student':  
29 - handlers: ['default']  
30 - level: 'DEBUG'  
31 - propagate: false  
32 -  
33 - 'aprendizations.learnapp':  
34 - handlers: ['default']  
35 - level: 'DEBUG'  
36 - propagate: false  
37 -  
38 - 'aprendizations.questions':  
39 - handlers: ['default']  
40 - level: 'DEBUG'  
41 - propagate: false  
42 -  
43 - 'aprendizations.tools':  
44 - handlers: ['default']  
45 - level: 'DEBUG'  
46 - propagate: false  
47 -  
48 - 'aprendizations.serve':  
49 - handlers: ['default']  
50 - level: 'DEBUG'  
51 - propagate: false 21 + '':
  22 + handlers: ['default']
  23 + level: 'DEBUG'
  24 +
  25 + 'aprendizations.factory':
  26 + handlers: ['default']
  27 + level: 'DEBUG'
  28 + propagate: false
  29 +
  30 + 'aprendizations.student':
  31 + handlers: ['default']
  32 + level: 'DEBUG'
  33 + propagate: false
  34 +
  35 + 'aprendizations.learnapp':
  36 + handlers: ['default']
  37 + level: 'DEBUG'
  38 + propagate: false
  39 +
  40 + 'aprendizations.questions':
  41 + handlers: ['default']
  42 + level: 'DEBUG'
  43 + propagate: false
  44 +
  45 + 'aprendizations.tools':
  46 + handlers: ['default']
  47 + level: 'DEBUG'
  48 + propagate: false
  49 +
  50 + 'aprendizations.serve':
  51 + handlers: ['default']
  52 + level: 'DEBUG'
  53 + propagate: false
demo/astronomy.yaml
@@ -3,11 +3,11 @@ @@ -3,11 +3,11 @@
3 # optional values applied to each topic, if undefined there 3 # optional values applied to each topic, if undefined there
4 # ---------------------------------------------------------------------------- 4 # ----------------------------------------------------------------------------
5 5
6 -# defaults: 6 +# defaults: FIXME not working
7 # file: questions.yaml 7 # file: questions.yaml
8 # shuffle_questions: true 8 # shuffle_questions: true
9 # choose: 6 9 # choose: 6
10 -# max_tries: 2 10 +# max_tries: 200
11 # forgetting_factor: 0.97 11 # forgetting_factor: 0.97
12 # min_level: 0.01 12 # min_level: 0.01
13 # append_wrong: true 13 # append_wrong: true
demo/astronomy/solar-system/questions.yaml
@@ -28,6 +28,7 @@ @@ -28,6 +28,7 @@
28 - Têm todos o mesmo tamanho 28 - Têm todos o mesmo tamanho
29 # opcional 29 # opcional
30 correct: 2 30 correct: 2
  31 + # discount: true
31 shuffle: false 32 shuffle: false
32 solution: | 33 solution: |
33 O maior planeta é Júpiter. Tem uma massa 1000 vezes inferior ao Sol, mas 34 O maior planeta é Júpiter. Tem uma massa 1000 vezes inferior ao Sol, mas
demo/math/addition/questions.yaml
@@ -5,6 +5,7 @@ @@ -5,6 +5,7 @@
5 script: addition-two-digits.py 5 script: addition-two-digits.py
6 args: [10, 20] 6 args: [10, 20]
7 7
  8 +# ---------------------------------------------------------------------------
8 - type: checkbox 9 - type: checkbox
9 ref: addition-properties 10 ref: addition-properties
10 title: Propriedades da adição 11 title: Propriedades da adição
@@ -17,6 +18,6 @@ @@ -17,6 +18,6 @@
17 - Propriedade comutativa, $x+y=y+x$. 18 - Propriedade comutativa, $x+y=y+x$.
18 # wrong 19 # wrong
19 - Existência de elemento absorvente, $x+1=1$. 20 - Existência de elemento absorvente, $x+1=1$.
20 - correct: [1, 1, 1, 1, -1] 21 + correct: [1, 1, 1, 1, 0]
21 solution: | 22 solution: |
22 A adição não tem elemento absorvente. 23 A adição não tem elemento absorvente.
package-lock.json
@@ -2,244 +2,26 @@ @@ -2,244 +2,26 @@
2 "requires": true, 2 "requires": true,
3 "lockfileVersion": 1, 3 "lockfileVersion": 1,
4 "dependencies": { 4 "dependencies": {
5 - "@babel/code-frame": {  
6 - "version": "7.5.5",  
7 - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.5.5.tgz",  
8 - "integrity": "sha512-27d4lZoomVyo51VegxI20xZPuSHusqbQag/ztrBC7wegWoQ1nLREPVSKSW8byhTlzTKyNE4ifaTA6lCp7JjpFw==",  
9 - "requires": {  
10 - "@babel/highlight": "^7.0.0"  
11 - }  
12 - },  
13 - "@babel/core": {  
14 - "version": "7.6.0",  
15 - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.6.0.tgz",  
16 - "integrity": "sha512-FuRhDRtsd6IptKpHXAa+4WPZYY2ZzgowkbLBecEDDSje1X/apG7jQM33or3NdOmjXBKWGOg4JmSiRfUfuTtHXw==",  
17 - "requires": {  
18 - "@babel/code-frame": "^7.5.5",  
19 - "@babel/generator": "^7.6.0",  
20 - "@babel/helpers": "^7.6.0",  
21 - "@babel/parser": "^7.6.0",  
22 - "@babel/template": "^7.6.0",  
23 - "@babel/traverse": "^7.6.0",  
24 - "@babel/types": "^7.6.0",  
25 - "convert-source-map": "^1.1.0",  
26 - "debug": "^4.1.0",  
27 - "json5": "^2.1.0",  
28 - "lodash": "^4.17.13",  
29 - "resolve": "^1.3.2",  
30 - "semver": "^5.4.1",  
31 - "source-map": "^0.5.0"  
32 - }  
33 - },  
34 - "@babel/generator": {  
35 - "version": "7.6.0",  
36 - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.6.0.tgz",  
37 - "integrity": "sha512-Ms8Mo7YBdMMn1BYuNtKuP/z0TgEIhbcyB8HVR6PPNYp4P61lMsABiS4A3VG1qznjXVCf3r+fVHhm4efTYVsySA==",  
38 - "requires": {  
39 - "@babel/types": "^7.6.0",  
40 - "jsesc": "^2.5.1",  
41 - "lodash": "^4.17.13",  
42 - "source-map": "^0.5.0",  
43 - "trim-right": "^1.0.1"  
44 - }  
45 - },  
46 - "@babel/helper-function-name": {  
47 - "version": "7.1.0",  
48 - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.1.0.tgz",  
49 - "integrity": "sha512-A95XEoCpb3TO+KZzJ4S/5uW5fNe26DjBGqf1o9ucyLyCmi1dXq/B3c8iaWTfBk3VvetUxl16e8tIrd5teOCfGw==",  
50 - "requires": {  
51 - "@babel/helper-get-function-arity": "^7.0.0",  
52 - "@babel/template": "^7.1.0",  
53 - "@babel/types": "^7.0.0"  
54 - }  
55 - },  
56 - "@babel/helper-get-function-arity": {  
57 - "version": "7.0.0",  
58 - "resolved": "https://registry.npmjs.org/@babel/helper-get-function-arity/-/helper-get-function-arity-7.0.0.tgz",  
59 - "integrity": "sha512-r2DbJeg4svYvt3HOS74U4eWKsUAMRH01Z1ds1zx8KNTPtpTL5JAsdFv8BNyOpVqdFhHkkRDIg5B4AsxmkjAlmQ==",  
60 - "requires": {  
61 - "@babel/types": "^7.0.0"  
62 - }  
63 - },  
64 - "@babel/helper-split-export-declaration": {  
65 - "version": "7.4.4",  
66 - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.4.4.tgz",  
67 - "integrity": "sha512-Ro/XkzLf3JFITkW6b+hNxzZ1n5OQ80NvIUdmHspih1XAhtN3vPTuUFT4eQnela+2MaZ5ulH+iyP513KJrxbN7Q==",  
68 - "requires": {  
69 - "@babel/types": "^7.4.4"  
70 - }  
71 - },  
72 - "@babel/helpers": {  
73 - "version": "7.6.0",  
74 - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.6.0.tgz",  
75 - "integrity": "sha512-W9kao7OBleOjfXtFGgArGRX6eCP0UEcA2ZWEWNkJdRZnHhW4eEbeswbG3EwaRsnQUAEGWYgMq1HsIXuNNNy2eQ==",  
76 - "requires": {  
77 - "@babel/template": "^7.6.0",  
78 - "@babel/traverse": "^7.6.0",  
79 - "@babel/types": "^7.6.0"  
80 - }  
81 - },  
82 - "@babel/highlight": {  
83 - "version": "7.5.0",  
84 - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.5.0.tgz",  
85 - "integrity": "sha512-7dV4eu9gBxoM0dAnj/BCFDW9LFU0zvTrkq0ugM7pnHEgguOEeOz1so2ZghEdzviYzQEED0r4EAgpsBChKy1TRQ==",  
86 - "requires": {  
87 - "chalk": "^2.0.0",  
88 - "esutils": "^2.0.2",  
89 - "js-tokens": "^4.0.0"  
90 - }  
91 - },  
92 - "@babel/parser": {  
93 - "version": "7.6.0",  
94 - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.6.0.tgz",  
95 - "integrity": "sha512-+o2q111WEx4srBs7L9eJmcwi655eD8sXniLqMB93TBK9GrNzGrxDWSjiqz2hLU0Ha8MTXFIP0yd9fNdP+m43ZQ=="  
96 - },  
97 - "@babel/template": {  
98 - "version": "7.6.0",  
99 - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.6.0.tgz",  
100 - "integrity": "sha512-5AEH2EXD8euCk446b7edmgFdub/qfH1SN6Nii3+fyXP807QRx9Q73A2N5hNwRRslC2H9sNzaFhsPubkS4L8oNQ==",  
101 - "requires": {  
102 - "@babel/code-frame": "^7.0.0",  
103 - "@babel/parser": "^7.6.0",  
104 - "@babel/types": "^7.6.0"  
105 - }  
106 - },  
107 - "@babel/traverse": {  
108 - "version": "7.6.0",  
109 - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.6.0.tgz",  
110 - "integrity": "sha512-93t52SaOBgml/xY74lsmt7xOR4ufYvhb5c5qiM6lu4J/dWGMAfAh6eKw4PjLes6DI6nQgearoxnFJk60YchpvQ==",  
111 - "requires": {  
112 - "@babel/code-frame": "^7.5.5",  
113 - "@babel/generator": "^7.6.0",  
114 - "@babel/helper-function-name": "^7.1.0",  
115 - "@babel/helper-split-export-declaration": "^7.4.4",  
116 - "@babel/parser": "^7.6.0",  
117 - "@babel/types": "^7.6.0",  
118 - "debug": "^4.1.0",  
119 - "globals": "^11.1.0",  
120 - "lodash": "^4.17.13"  
121 - }  
122 - },  
123 - "@babel/types": {  
124 - "version": "7.6.1",  
125 - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.6.1.tgz",  
126 - "integrity": "sha512-X7gdiuaCmA0uRjCmRtYJNAVCc/q+5xSgsfKJHqMN4iNLILX39677fJE1O40arPMh0TTtS9ItH67yre6c7k6t0g==",  
127 - "requires": {  
128 - "esutils": "^2.0.2",  
129 - "lodash": "^4.17.13",  
130 - "to-fast-properties": "^2.0.0"  
131 - }  
132 - },  
133 "@fortawesome/fontawesome-free": { 5 "@fortawesome/fontawesome-free": {
134 "version": "5.11.2", 6 "version": "5.11.2",
135 "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz", 7 "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.11.2.tgz",
136 "integrity": "sha512-XiUPoS79r1G7PcpnNtq85TJ7inJWe0v+b5oZJZKb0pGHNIV6+UiNeQWiFGmuQ0aj7GEhnD/v9iqxIsjuRKtEnQ==" 8 "integrity": "sha512-XiUPoS79r1G7PcpnNtq85TJ7inJWe0v+b5oZJZKb0pGHNIV6+UiNeQWiFGmuQ0aj7GEhnD/v9iqxIsjuRKtEnQ=="
137 }, 9 },
138 - "ansi-styles": {  
139 - "version": "3.2.1",  
140 - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz",  
141 - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==",  
142 - "requires": {  
143 - "color-convert": "^1.9.0"  
144 - }  
145 - },  
146 - "chalk": {  
147 - "version": "2.4.2",  
148 - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",  
149 - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==",  
150 - "requires": {  
151 - "ansi-styles": "^3.2.1",  
152 - "escape-string-regexp": "^1.0.5",  
153 - "supports-color": "^5.3.0"  
154 - }  
155 - },  
156 "codemirror": { 10 "codemirror": {
157 - "version": "5.49.0",  
158 - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.0.tgz",  
159 - "integrity": "sha512-Hyzr0HToBdZpLBN9dYFO/KlJAsKH37/cXVHPAqa+imml0R92tb9AkmsvjnXL+SluEvjjdfkDgRjc65NG5jnMYA=="  
160 - },  
161 - "color-convert": {  
162 - "version": "1.9.3",  
163 - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz",  
164 - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==",  
165 - "requires": {  
166 - "color-name": "1.1.3"  
167 - }  
168 - },  
169 - "color-name": {  
170 - "version": "1.1.3",  
171 - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",  
172 - "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" 11 + "version": "5.49.2",
  12 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.49.2.tgz",
  13 + "integrity": "sha512-dwJ2HRPHm8w51WB5YTF9J7m6Z5dtkqbU9ntMZ1dqXyFB9IpjoUFDj80ahRVEoVanfIp6pfASJbOlbWdEf8FOzQ=="
173 }, 14 },
174 "commander": { 15 "commander": {
175 "version": "3.0.1", 16 "version": "3.0.1",
176 "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.1.tgz", 17 "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.1.tgz",
177 "integrity": "sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ==" 18 "integrity": "sha512-UNgvDd+csKdc9GD4zjtkHKQbT8Aspt2jCBqNSPp53vAS0L1tS9sXB2TCEOPHJ7kt9bN/niWkYj8T3RQSoMXdSQ=="
178 }, 19 },
179 - "convert-source-map": {  
180 - "version": "1.6.0",  
181 - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz",  
182 - "integrity": "sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==",  
183 - "requires": {  
184 - "safe-buffer": "~5.1.1"  
185 - }  
186 - },  
187 - "debug": {  
188 - "version": "4.1.1",  
189 - "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",  
190 - "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",  
191 - "requires": {  
192 - "ms": "^2.1.1"  
193 - }  
194 - },  
195 - "escape-string-regexp": {  
196 - "version": "1.0.5",  
197 - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",  
198 - "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ="  
199 - },  
200 "esm": { 20 "esm": {
201 "version": "3.2.25", 21 "version": "3.2.25",
202 "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz", 22 "resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
203 "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==" 23 "integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA=="
204 }, 24 },
205 - "esutils": {  
206 - "version": "2.0.3",  
207 - "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",  
208 - "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="  
209 - },  
210 - "globals": {  
211 - "version": "11.12.0",  
212 - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz",  
213 - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="  
214 - },  
215 - "has-flag": {  
216 - "version": "3.0.0",  
217 - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",  
218 - "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0="  
219 - },  
220 - "js-tokens": {  
221 - "version": "4.0.0",  
222 - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",  
223 - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="  
224 - },  
225 - "jsesc": {  
226 - "version": "2.5.2",  
227 - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",  
228 - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA=="  
229 - },  
230 - "json5": {  
231 - "version": "2.1.0",  
232 - "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.0.tgz",  
233 - "integrity": "sha512-8Mh9h6xViijj36g7Dxi+Y4S6hNGV96vcJZr/SrlHh1LR/pEn/8j/+qIBbs44YKl69Lrfctp4QD+AdWLTMqEZAQ==",  
234 - "requires": {  
235 - "minimist": "^1.2.0"  
236 - }  
237 - },  
238 - "lodash": {  
239 - "version": "4.17.15",  
240 - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.15.tgz",  
241 - "integrity": "sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A=="  
242 - },  
243 "mathjax": { 25 "mathjax": {
244 "version": "3.0.0", 26 "version": "3.0.0",
245 "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.0.0.tgz", 27 "resolved": "https://registry.npmjs.org/mathjax/-/mathjax-3.0.0.tgz",
@@ -258,56 +40,15 @@ @@ -258,56 +40,15 @@
258 } 40 }
259 }, 41 },
260 "mdbootstrap": { 42 "mdbootstrap": {
261 - "version": "4.8.10",  
262 - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.8.10.tgz",  
263 - "integrity": "sha512-pUjs7Vds4J+MwepOo4obUy7bQ5aMeB8j1c3IxIcEYXOXmn8GOWMSpiRcfSXpH9R4Fgdfie++e0fm5+SebRnTYA==",  
264 - "requires": {  
265 - "@babel/core": "^7.3.3"  
266 - }  
267 - },  
268 - "minimist": {  
269 - "version": "1.2.0",  
270 - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz",  
271 - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" 43 + "version": "4.9.0",
  44 + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.9.0.tgz",
  45 + "integrity": "sha512-6R3j5D9Qmp+Aa90FblOVAwVDSqpAICYW2dpNxh6uaVB9E9MCaBLdaTKLrXCB7xznReHEaA57pNABXgFoi2z7Rg=="
272 }, 46 },
273 "mj-context-menu": { 47 "mj-context-menu": {
274 "version": "0.2.0", 48 "version": "0.2.0",
275 "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.2.0.tgz", 49 "resolved": "https://registry.npmjs.org/mj-context-menu/-/mj-context-menu-0.2.0.tgz",
276 "integrity": "sha512-yJxrWBHCjFZEHsZgfs7m5g9OSCNzsVYadW6f6lX3pgZL67vmodtSW/4zhsYmuDKweXfHs0M1kJge1uQIasWA+g==" 50 "integrity": "sha512-yJxrWBHCjFZEHsZgfs7m5g9OSCNzsVYadW6f6lX3pgZL67vmodtSW/4zhsYmuDKweXfHs0M1kJge1uQIasWA+g=="
277 }, 51 },
278 - "ms": {  
279 - "version": "2.1.2",  
280 - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",  
281 - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="  
282 - },  
283 - "path-parse": {  
284 - "version": "1.0.6",  
285 - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz",  
286 - "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw=="  
287 - },  
288 - "resolve": {  
289 - "version": "1.12.0",  
290 - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.12.0.tgz",  
291 - "integrity": "sha512-B/dOmuoAik5bKcD6s6nXDCjzUKnaDvdkRyAk6rsmsKLipWj4797iothd7jmmUhWTfinVMU+wc56rYKsit2Qy4w==",  
292 - "requires": {  
293 - "path-parse": "^1.0.6"  
294 - }  
295 - },  
296 - "safe-buffer": {  
297 - "version": "5.1.2",  
298 - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",  
299 - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="  
300 - },  
301 - "semver": {  
302 - "version": "5.7.1",  
303 - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz",  
304 - "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ=="  
305 - },  
306 - "source-map": {  
307 - "version": "0.5.7",  
308 - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz",  
309 - "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w="  
310 - },  
311 "speech-rule-engine": { 52 "speech-rule-engine": {
312 "version": "3.0.0-beta.6", 53 "version": "3.0.0-beta.6",
313 "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-3.0.0-beta.6.tgz", 54 "resolved": "https://registry.npmjs.org/speech-rule-engine/-/speech-rule-engine-3.0.0-beta.6.tgz",
@@ -318,24 +59,6 @@ @@ -318,24 +59,6 @@
318 "xmldom-sre": "^0.1.31" 59 "xmldom-sre": "^0.1.31"
319 } 60 }
320 }, 61 },
321 - "supports-color": {  
322 - "version": "5.5.0",  
323 - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",  
324 - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==",  
325 - "requires": {  
326 - "has-flag": "^3.0.0"  
327 - }  
328 - },  
329 - "to-fast-properties": {  
330 - "version": "2.0.0",  
331 - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz",  
332 - "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4="  
333 - },  
334 - "trim-right": {  
335 - "version": "1.0.1",  
336 - "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz",  
337 - "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM="  
338 - },  
339 "wicked-good-xpath": { 62 "wicked-good-xpath": {
340 "version": "1.3.0", 63 "version": "1.3.0",
341 "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz", 64 "resolved": "https://registry.npmjs.org/wicked-good-xpath/-/wicked-good-xpath-1.3.0.tgz",
@@ -3,9 +3,9 @@ @@ -3,9 +3,9 @@
3 "email": "mjsb@uevora.pt", 3 "email": "mjsb@uevora.pt",
4 "dependencies": { 4 "dependencies": {
5 "@fortawesome/fontawesome-free": "^5.11.2", 5 "@fortawesome/fontawesome-free": "^5.11.2",
6 - "codemirror": "^5.49.0", 6 + "codemirror": "^5.49.2",
7 "mathjax": "^3", 7 "mathjax": "^3",
8 - "mdbootstrap": "^4.8.10" 8 + "mdbootstrap": "^4.9.0"
9 }, 9 },
10 "private": true 10 "private": true
11 } 11 }