Commit b55c1442a462544142e735490a43391d13f23419

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

- fixed most mypy and pyright errors

- questions.py cleanup. Replaced __init__() by gen() method
- setup.py now correctly specifies compatible sqlalchemy version
BUGS.md
... ... @@ -3,14 +3,19 @@
3 3  
4 4 - internal server error ao fazer logout no macos python3.8
5 5 - GET can get filtered by browser cache
6   -- topicos chapter devem ser automaticamente completos assim que as dependencias são satisfeitas. Nao devia ser necessario (ou possivel?) clicar neles.
7   -- topicos do tipo learn deviam por defeito nao ser randomizados e assumir ficheiros `learn.yaml`.
8   -- internal server error 500... experimentar cenario: aluno tem login efectuado, prof muda pw e faz login/logout. aluno obtem erro 500.
  6 +- topicos chapter devem ser automaticamente completos assim que as dependencias
  7 + são satisfeitas. Nao devia ser necessario (ou possivel?) clicar neles.
  8 +- topicos do tipo learn deviam por defeito nao ser randomizados e assumir
  9 + ficheiros `learn.yaml`.
  10 +- internal server error 500... experimentar cenario: aluno tem login efectuado,
  11 + prof muda pw e faz login/logout. aluno obtem erro 500.
9 12 - radio sem options rebenta com aprendizations --check
10   -- chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos pensam que já terminaram e não conseguem progredir por causa das dependencias.
  13 +- chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos
  14 + pensam que já terminaram e não conseguem progredir por causa das
  15 + dependencias.
11 16 - if topic deps on invalid ref terminates server with "Unknown error".
12 17 - warning nos topics que não são usados em nenhum curso
13   -- nao esta a seguir o max_tries definido no ficheiro de dependencias.
  18 +- nao esta a seguir o `max_tries` definido no ficheiro de dependencias.
14 19 - devia mostrar timeout para o aluno saber a razao.
15 20 - permitir configuracao para escolher entre static files locais ou remotos
16 21 - shift-enter não está a funcionar
... ... @@ -19,67 +24,92 @@
19 24 # TODO
20 25  
21 26 - shuffle das perguntas dentro de um topico
22   -- alterar tabelas para incluir email de recuperacao de password (e outros avisos)
23   -- registar last_seen e remover os antigos de cada vez que houver um login.
  27 +- alterar tabelas para incluir email de recuperacao de password (e outros
  28 + avisos)
  29 +- registar `last_seen` e remover os antigos de cada vez que houver um login.
24 30 - indicar qtos topicos faltam (>=50%) para terminar o curso.
25 31 - ao fim de 3 tentativas com password errada, envia email com nova password.
26   -- mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias.
27   -- mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos.
  32 +- mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo
  33 + expande as dependencias.
  34 +- mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado
  35 + topicos.
28 36 - botão não sei...
29 37 - mostrar icon "loading..." enquanto está a corrigir uma pergunta.
30 38 - session management. close after inactive time.
31 39 - radio e checkboxes, aceitar numeros como seleccao das opcoes.
32   -- reload das perguntas enquanto online. ver signal em http://stackabuse.com/python-async-await-tutorial/
  40 +- reload das perguntas enquanto online. ver signal em
  41 + [](http://stackabuse.com/python-async-await-tutorial/)
33 42 - tabela de progresso de todos os alunos por topico.
34 43 - tabela com perguntas / quantidade de respostas certas/erradas.
35 44 - tabela com topicos / quantidade de estrelas.
36 45 - pymips: activar/desactivar instruções
37 46 - titulos das perguntas não suportam markdown.
38   -- pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta.
  47 +- pagina report que permita ver tabela alunos/topicos, estatisticas perguntas
  48 + mais falhadas, tempo médio por pergunta.
39 49 - normalizar com perguntations.
40 50  
41 51 # FIXED
42 52  
43   -- templates question-*.html tem input hidden question_ref que não é usado. remover?
  53 +- templates question-*.html tem input hidden question_ref que não é usado.
  54 + remover?
44 55 - goals se forem do tipo chapter deve importar todas as dependencias do chapter.
45   -- initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
46   -- dependencias que não são goals de um curso, só devem aparecer se ainda não tiverem sido feitas.
  56 +- initdb da integrity error se no mesmo comando existirem alunos repetidos
  57 + (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
  58 +- dependencias que não são goals de um curso, só devem aparecer se ainda não
  59 + tiverem sido feitas.
47 60 - ir para inicio da pagina quando le nova pergunta.
48 61 - CRITICAL nao esta a guardar o progresso na base de dados.
49 62 - mesma ref no mesmo ficheiro não é detectado.
50 63 - enter nas respostas mostra json
51   -- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
  64 +- apos clicar no botao responder, inactivar o input (importante quando o tempo
  65 + de correcção é grande)
52 66 - double click submits twice.
53   -- 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.
  67 +- checkbox devia ter correct no intervalo [0,1] tal como radio. em caso de
  68 + desconto a correccção faz 2*x-1. isto permite a mesma semantica nos dois
  69 + tipos de perguntas.
54 70 - marking all options right in a radio question breaks!
55 71 - implementar servidor http com redirect para https.
56 72 - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.
57 73 - click numa opcao checkbox fora da checkbox+label não está a funcionar.
58   -- 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.
59   -- QFactory.generate() devia fazer run da gen_async, ou remover.
  74 +- mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam
  75 + centradas em toda a coluna mas apenas na largura do parágrafo.
  76 +- QFactory.generate() devia fazer run da `gen_async,` ou remover.
60 77 - classificacoes so devia mostrar os que ja fizeram alguma coisa
61 78 - impedir que quando students.db não é encontrado, crie um ficheiro vazio.
62   -- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.
63   -- 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.
  79 +- permite definir goal, mas nao verifica se esta no grafo. rebenta no
  80 + `start_topic`.
  81 +- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as
  82 + imagengs porque o `current_topic` passa a None antes de carregar no botao
  83 + continuar. O caminho é prefix+None e dá erro.
64 84 - caixas com os cursos não se ajustam bem com ecran estreito.
65   -- obter rankings por curso GET course=course_id
66   -- no curso de linear algebra, as perguntas estao shuffled, mas nao deviam estar... nao esta a obedecer a keyword shuffle.
  85 +- obter rankings por curso `GET course=course_id`
  86 +- no curso de linear algebra, as perguntas estao shuffled, mas nao deviam
  87 + estar... nao esta a obedecer a keyword shuffle.
67 88 - menu nao mostra as opcoes correctamente
68 89 - finish topic vai para a lista de cursos. devia ficar no mesmo curso.
69 90 - mathjax nao esta a correr sobre o titulo.
70 91 - forgetting factor is hardcoded in student.py
71 92 - add aprendizatons --version
72   -- se aluno abre dois tabs no browser, conseque navegar em simultaneo para perguntas diferentes. quando submete uma delas dá asneira. Tem de haver um campo hidden que tenha um céodigo único que indique qual a pergunta. do lado do servidor apnas há o codigo da pergunta corrente, se forem diferentes faz redirect para /.
  93 +- se aluno abre dois tabs no browser, conseque navegar em simultaneo para
  94 + perguntas diferentes. quando submete uma delas dá asneira. Tem de haver um
  95 + campo hidden que tenha um céodigo único que indique qual a pergunta. do lado
  96 + do servidor apnas há o codigo da pergunta corrente, se forem diferentes faz
  97 + redirect para /.
73 98 - nos topicos learn.yaml, qd falha acrescenta no fim. nao faz sentido.
74   -- não esta a fazer render correcto de tabelas nas opcoes checkbox. e.g. information-theory/source-coding-theory/block-codes
  99 +- não esta a fazer render correcto de tabelas nas opcoes checkbox. e.g.
  100 + `information-theory/source-coding-theory/block-codes`
75 101 - max tries nas perguntas.
76 102 - mostrar feedback/solucoes quando acerta, ou excede max tries.
77   -- quando se pressiona "responde" rapido (enquanto a animacao dura), a pergunta passa para a seguinte sem haver o correspondente redraw, ou seja a proxima resposta nao é a da pergunta mostrada.
  103 +- quando se pressiona "responde" rapido (enquanto a animacao dura), a pergunta
  104 + passa para a seguinte sem haver o correspondente redraw, ou seja a proxima
  105 + resposta nao é a da pergunta mostrada.
78 106 - botao para mostrar a solução quando se acerta.
79 107 - não está a guardar o resultado no final do topico
80   -- esta a permitir 2 logins em simultaneo do mesmo user. fica tudo baralhado se mxerem em simultaneo...
  108 +- esta a permitir 2 logins em simultaneo do mesmo user. fica tudo baralhado se
  109 + mxerem em simultaneo...
81 110 - errar no ultimo topico nao mostra solucao?
82   -- quando a pergunta devolve comments, este é apresentado, mas fica persistente nas tentativas seguintes. devia ser limpo apos a segunda submissao.
  111 +- quando a pergunta devolve comments, este é apresentado, mas fica persistente
  112 + nas tentativas seguintes. devia ser limpo apos a segunda submissao.
83 113 - na definicao dos topicos, indicar:
84 114 "file: questions.yaml" (default questions.yaml)
85 115 "shuffle: True/False" (default False)
... ... @@ -94,9 +124,11 @@
94 124 - each topic only loads a sample of K questions (max) in random order.
95 125 - change password modal nao aparece no ipad (safari e firefox)
96 126 - detect questions in questions.yaml without ref -> error ou generate default.
97   -- generators e correct scripts que durem muito tempo bloqueiam o eventloop do tornado.
  127 +- generators e correct scripts que durem muito tempo bloqueiam o eventloop do
  128 + tornado.
98 129 - servir imagens/ficheiros.
99   -- radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma selecção aleatoria destas (so com 1 certa).
  130 +- radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma
  131 + selecção aleatoria destas (so com 1 certa).
100 132 - checkbox: cada opção pode ser uma dupla (certo, errado) sendo escolhida uma aleatória.
101 133 - async/threadpool no bcrypt do initdb.
102 134 - numero de estrelas depende da proporcao entre certas e erradas.
... ... @@ -107,10 +139,12 @@
107 139 - remover learn.css uma vez que nao é usado em lado nenhum?
108 140 - check if user already logged in
109 141 - mover javascript para ficheiros externos e carregar com script defer src
110   -- implementar xsrf. Ver [http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection]()
111   -- se refs de um topic estao invalidos, nao carrega esse topico. devia haver um error nos logs a indicar qual o ref invalido.
  142 +- implementar xsrf. Ver [](http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection)
  143 +- se refs de um topic estao invalidos, nao carrega esse topico. devia haver um
  144 + error nos logs a indicar qual o ref invalido.
112 145 - link directo para topico nao valida se topico esta unlocked.
113   -- templates not working: quesntion-information, question-warning (remove all informative panels??)
  146 +- templates not working: quesntion-information, question-warning (remove all
  147 + informative panels??)
114 148 - enderecos errados dao internal error.
115 149 - barra de progresso nao está visível.
116 150 - tabs em textarea nao funcionam correctamente (insere 1 espaco em vez de 4)
... ... @@ -120,14 +154,15 @@
120 154 - animação no final de cada topico para se perceber a transição
121 155 - "<" is not escaped in markdown.
122 156 - Está a mostrar a solução em 'comments'!!!
123   -- database: answers não tem referencia para o topico, so para question_ref
  157 +- database: answers não tem referencia para o topico, so para `question_ref`
124 158 - melhorar markdown das tabelas.
125 159 - gravar evolucao na bd no final de cada topico.
126 160 - submeter questoes radio, da erro se nao escolher nenhuma opção.
127 161 - indentação da primeira linha de código não funciona.
128 162 - markdown com o mistune.
129 163 - change password in maintopics.html, falta menu para lançar modal
130   -- ver documentacao de migracao para networkx 2.0 https://networkx.github.io/documentation/stable/release/migration_guide_from_1.x_to_2.0.html
  164 +- ver documentacao de migracao para networkx 2.0
  165 + [](https://networkx.github.io/documentation/stable/release/migration_guide_from_1.x_to_2.0.html)
131 166 - script para adicionar users/reset passwords.
132 167 - os topicos locked devem estar inactivos no sidebar.
133 168 - enter faz GET /question, que responde com json no ecran. (solution: disabled enter)
... ... @@ -136,7 +171,8 @@
136 171 - indicar o topico actual no sidebar
137 172 - reload da página rebenta o estado.
138 173 - text deve mostrar no html os valores iniciais de ans, se existir
139   -- nao permite perguntas repetidas. iterar questions da configuracao em vez das do ficheiro. ver app.py linha 223.
  174 +- nao permite perguntas repetidas. iterar questions da configuracao em vez das
  175 + do ficheiro. ver app.py linha 223.
140 176 - level depender do numero de respostas correctas
141 177 - pymips a funcionar
142 178 - logs mostram que está a gerar cada pergunta 2 vezes...??
... ... @@ -148,15 +184,18 @@
148 184 - se students.db não existe, rebenta.
149 185 - não entra à primeira
150 186 - configuração e linha de comando.
151   -- o browser é redireccionado para /question em vez de fazer um post?? quando se pressiona enter numa caixa text edit.
  187 +- o browser é redireccionado para /question em vez de fazer um post?? quando se
  188 + pressiona enter numa caixa text edit.
152 189 - load/save the knowledge state of the student
153 190 - servir ficheiros de public temporariamente
154 191 - path dos generators scripts mal construido
155 192 - questions hardcoded in LearnApp.
156 193 - Factory para cada pergunta individual em vez de pool
157   -- implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona, enter submete.
  194 +- implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona,
  195 + enter submete.
158 196 - logging
159   -- textarea tem codigo para preencher o texto, mas ja não é necessário porque pergunta não é reloaded.
  197 +- textarea tem codigo para preencher o texto, mas ja não é necessário porque
  198 + pergunta não é reloaded.
160 199 - gravar answers -> db
161 200 - como gerar key para secure cookie.
162 201 - https. certificados selfsigned, no-ip nao suporta certificados
... ... @@ -171,4 +210,5 @@
171 210 - clicar texto selecciona checkboxes/radio.
172 211 - focar text/textarea
173 212 - implementar template base das perguntas base e estender para cada tipo.
174   -- submissão com enter em perguntas text faz get? provavelmente está a fazer o submit do form em vez de ir pelo ajax.
  213 +- submissão com enter em perguntas text faz get? provavelmente está a fazer o
  214 + submit do form em vez de ir pelo ajax.
... ...
aprendizations/initdb.py
... ... @@ -14,6 +14,8 @@ from concurrent.futures import ThreadPoolExecutor
14 14 # third party libraries
15 15 import bcrypt
16 16 import sqlalchemy as sa
  17 +import sqlalchemy.orm as orm
  18 +from sqlalchemy.exc import IntegrityError
17 19  
18 20 # this project
19 21 from aprendizations.models import Base, Student
... ... @@ -143,7 +145,7 @@ def main():
143 145 print(f'Using database: {args.db}')
144 146 engine = sa.create_engine(f'sqlite:///{args.db}', echo=False)
145 147 Base.metadata.create_all(engine) # Creates schema if needed
146   - session = sa.orm.sessionmaker(bind=engine)()
  148 + session = orm.sessionmaker(bind=engine)()
147 149  
148 150 # --- build list of students to insert/update
149 151 students = []
... ... @@ -181,7 +183,7 @@ def main():
181 183 password=s['pw'])
182 184 for s in new_students])
183 185 session.commit()
184   - except sa.exc.IntegrityError:
  186 + except IntegrityError:
185 187 print('!!! Integrity error. Aborted !!!\n')
186 188 session.rollback()
187 189  
... ...
aprendizations/learnapp.py
... ... @@ -458,7 +458,7 @@ class LearnApp():
458 458  
459 459 logger.info('Building questions factory:')
460 460 factory = dict()
461   - for tref in self.deps.nodes():
  461 + for tref in self.deps.nodes:
462 462 factory.update(self._factory_for(tref))
463 463  
464 464 logger.info('Factory has %s questions', len(factory))
... ...
aprendizations/main.py
... ... @@ -7,7 +7,7 @@ Setup configurations and then runs the application.
7 7  
8 8 # python standard library
9 9 import argparse
10   -import logging
  10 +import logging.config
11 11 from os import environ, path
12 12 import ssl
13 13 import sys
... ...
aprendizations/questions.py
... ... @@ -19,7 +19,6 @@ from aprendizations.tools import run_script, run_script_async
19 19 # setup logger for this module
20 20 logger = logging.getLogger(__name__)
21 21  
22   -
23 22 QDict = NewType('QDict', Dict[str, Any])
24 23  
25 24  
... ... @@ -37,8 +36,11 @@ class Question(dict):
37 36 for each student.
38 37 Instances can shuffle options or automatically generate questions.
39 38 '''
40   - def __init__(self, q: QDict) -> None:
41   - super().__init__(q)
  39 +
  40 + def gen(self) -> None:
  41 + '''
  42 + Sets defaults that are valid for any question type
  43 + '''
42 44  
43 45 # add required keys if missing
44 46 self.set_defaults(QDict({
... ... @@ -82,10 +84,12 @@ class QuestionRadio(Question):
82 84 choose (int) # only used if shuffle=True
83 85 '''
84 86  
85   - # ------------------------------------------------------------------------
86   - def __init__(self, q: QDict) -> None:
87   - super().__init__(q)
88   -
  87 + def gen(self) -> None:
  88 + '''
  89 + Sets defaults, performs checks and generates the actual question
  90 + by modifying the options and correct values
  91 + '''
  92 + super().gen()
89 93 try:
90 94 nopts = len(self['options'])
91 95 except KeyError as exc:
... ... @@ -212,8 +216,8 @@ class QuestionCheckbox(Question):
212 216 '''
213 217  
214 218 # ------------------------------------------------------------------------
215   - def __init__(self, q: QDict) -> None:
216   - super().__init__(q)
  219 + def gen(self) -> None:
  220 + super().gen()
217 221  
218 222 try:
219 223 nopts = len(self['options'])
... ... @@ -266,19 +270,6 @@ class QuestionCheckbox(Question):
266 270 f'Please fix "{self["ref"]}" in "{self["path"]}"')
267 271 logger.error(msg)
268 272 raise QuestionException(msg)
269   - # msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+')
270   - # msg1 = ('| Correct values in checkbox questions must be in the |')
271   - # msg2 = ('| interval [0.0, 1.0]. I will convert to the new |')
272   - # msg3 = ('| behavior, for now, but you should fix it. |')
273   - # msg4 = ('+------------------------------------------------------+')
274   - # logger.warning(msg0)
275   - # logger.warning(msg1)
276   - # logger.warning(msg2)
277   - # logger.warning(msg3)
278   - # logger.warning(msg4)
279   - # logger.warning('please fix "%s"', self["ref"])
280   - # # normalize to [0,1]
281   - # self['correct'] = [(x+1)/2 for x in self['correct']]
282 273  
283 274 # if an option is a list of (right, wrong), pick one
284 275 options = []
... ... @@ -334,9 +325,8 @@ class QuestionText(Question):
334 325 '''
335 326  
336 327 # ------------------------------------------------------------------------
337   - def __init__(self, q: QDict) -> None:
338   - super().__init__(q)
339   -
  328 + def gen(self) -> None:
  329 + super().gen()
340 330 self.set_defaults(QDict({
341 331 'text': '',
342 332 'correct': [], # no correct answers, always wrong
... ... @@ -362,12 +352,13 @@ class QuestionText(Question):
362 352 'transformations => never correct', self["ref"])
363 353  
364 354 # ------------------------------------------------------------------------
365   - def transform(self, ans):
  355 + def transform(self, ans: str):
366 356 '''apply optional filters to the answer'''
367 357  
  358 + # apply transformations in sequence
368 359 for transform in self['transform']:
369 360 if transform == 'remove_space': # removes all spaces
370   - ans = ans.replace(' ', '')
  361 + ans = re.sub(r'\s+', '', ans)
371 362 elif transform == 'trim': # removes spaces around
372 363 ans = ans.strip()
373 364 elif transform == 'normalize_space': # replaces multiple spaces by one
... ... @@ -386,7 +377,7 @@ class QuestionText(Question):
386 377 super().correct()
387 378  
388 379 if self['answer'] is not None:
389   - answer = self.transform(self['answer']) # apply transformations
  380 + answer = self.transform(self['answer'])
390 381 self['grade'] = 1.0 if answer in self['correct'] else 0.0
391 382  
392 383  
... ... @@ -403,8 +394,8 @@ class QuestionTextRegex(Question):
403 394 '''
404 395  
405 396 # ------------------------------------------------------------------------
406   - def __init__(self, q: QDict) -> None:
407   - super().__init__(q)
  397 + def gen(self) -> None:
  398 + super().gen()
408 399  
409 400 self.set_defaults(QDict({
410 401 'text': '',
... ... @@ -415,28 +406,19 @@ class QuestionTextRegex(Question):
415 406 if not isinstance(self['correct'], list):
416 407 self['correct'] = [self['correct']]
417 408  
418   - # converts patterns to compiled versions
419   - try:
420   - self['correct'] = [re.compile(a) for a in self['correct']]
421   - except Exception as exc:
422   - msg = f'Failed to compile regex in "{self["ref"]}"'
423   - logger.error(msg)
424   - raise QuestionException(msg) from exc
425   -
426 409 # ------------------------------------------------------------------------
427 410 def correct(self) -> None:
428 411 super().correct()
429 412 if self['answer'] is not None:
430   - self['grade'] = 0.0
431 413 for regex in self['correct']:
432 414 try:
433   - if regex.match(self['answer']):
  415 + if re.fullmatch(regex, self['answer']):
434 416 self['grade'] = 1.0
435 417 return
436 418 except TypeError:
437   - logger.error('While matching regex %s with answer "%s".',
438   - regex.pattern, self["answer"])
439   -
  419 + logger.error('While matching regex "%s" with answer "%s".',
  420 + regex, self['answer'])
  421 + self['grade'] = 0.0
440 422  
441 423 # ============================================================================
442 424 class QuestionNumericInterval(Question):
... ... @@ -449,8 +431,8 @@ class QuestionNumericInterval(Question):
449 431 '''
450 432  
451 433 # ------------------------------------------------------------------------
452   - def __init__(self, q: QDict) -> None:
453   - super().__init__(q)
  434 + def gen(self) -> None:
  435 + super().gen()
454 436  
455 437 self.set_defaults(QDict({
456 438 'text': '',
... ... @@ -510,8 +492,8 @@ class QuestionTextArea(Question):
510 492 '''
511 493  
512 494 # ------------------------------------------------------------------------
513   - def __init__(self, q: QDict) -> None:
514   - super().__init__(q)
  495 + def gen(self) -> None:
  496 + super().gen()
515 497  
516 498 self.set_defaults(QDict({
517 499 'text': '',
... ... @@ -591,8 +573,8 @@ class QuestionInformation(Question):
591 573 '''
592 574  
593 575 # ------------------------------------------------------------------------
594   - def __init__(self, q: QDict) -> None:
595   - super().__init__(q)
  576 + def gen(self) -> None:
  577 + super().gen()
596 578 self.set_defaults(QDict({
597 579 'text': '',
598 580 }))
... ... @@ -604,6 +586,44 @@ class QuestionInformation(Question):
604 586  
605 587  
606 588 # ============================================================================
  589 +def question_from(qdict: QDict) -> Question:
  590 + '''
  591 + Converts a question specified in a dict into an instance of Question()
  592 + '''
  593 + types = {
  594 + 'radio': QuestionRadio,
  595 + 'checkbox': QuestionCheckbox,
  596 + 'text': QuestionText,
  597 + 'text-regex': QuestionTextRegex,
  598 + 'numeric-interval': QuestionNumericInterval,
  599 + 'textarea': QuestionTextArea,
  600 + # -- informative panels --
  601 + 'information': QuestionInformation,
  602 + 'success': QuestionInformation,
  603 + 'warning': QuestionInformation,
  604 + 'alert': QuestionInformation,
  605 + }
  606 +
  607 + # Get class for this question type
  608 + try:
  609 + qclass = types[qdict['type']]
  610 + except KeyError:
  611 + logger.error('Invalid type "%s" in "%s"',
  612 + qdict['type'], qdict['ref'])
  613 + raise
  614 +
  615 + # Create an instance of Question() of appropriate type
  616 + try:
  617 + qinstance = qclass(QDict(qdict))
  618 + except QuestionException:
  619 + logger.error('Error generating "%s" in %s/%s',
  620 + qdict['ref'], qdict['path'], qdict['filename'])
  621 + raise
  622 +
  623 + return qinstance
  624 +
  625 +
  626 +# ============================================================================
607 627 class QFactory():
608 628 '''
609 629 QFactory is a class that can generate question instances, e.g. by shuffling
... ... @@ -636,24 +656,8 @@ class QFactory():
636 656 grade = question['grade'] # get grade
637 657 '''
638 658  
639   - # Depending on the type of question, a different question class will be
640   - # instantiated. All these classes derive from the base class `Question`.
641   - _types = {
642   - 'radio': QuestionRadio,
643   - 'checkbox': QuestionCheckbox,
644   - 'text': QuestionText,
645   - 'text-regex': QuestionTextRegex,
646   - 'numeric-interval': QuestionNumericInterval,
647   - 'textarea': QuestionTextArea,
648   - # -- informative panels --
649   - 'information': QuestionInformation,
650   - 'success': QuestionInformation,
651   - 'warning': QuestionInformation,
652   - 'alert': QuestionInformation,
653   - }
654   -
655 659 def __init__(self, qdict: QDict = QDict({})) -> None:
656   - self.question = qdict
  660 + self.qdict = qdict
657 661  
658 662 # ------------------------------------------------------------------------
659 663 async def gen_async(self) -> Question:
... ... @@ -662,44 +666,28 @@ class QFactory():
662 666 which is a descendent of base class Question.
663 667 '''
664 668  
665   - logger.debug('generating %s...', self.question["ref"])
  669 + logger.debug('generating %s...', self.qdict["ref"])
666 670 # Shallow copy so that script generated questions will not replace
667 671 # the original generators
668   - question = self.question.copy()
669   - question['qid'] = str(uuid.uuid4()) # unique for each question
  672 + qdict = QDict(self.qdict.copy())
  673 + qdict['qid'] = str(uuid.uuid4()) # unique for each question
670 674  
671 675 # If question is of generator type, an external program will be run
672 676 # which will print a valid question in yaml format to stdout. This
673 677 # output is then yaml parsed into a dictionary `q`.
674   - if question['type'] == 'generator':
675   - logger.debug(' \\_ Running "%s".', question['script'])
676   - question.setdefault('args', [])
677   - question.setdefault('stdin', '')
678   - script = path.join(question['path'], question['script'])
  678 + if qdict['type'] == 'generator':
  679 + logger.debug(' \\_ Running "%s".', qdict['script'])
  680 + qdict.setdefault('args', [])
  681 + qdict.setdefault('stdin', '')
  682 + script = path.join(qdict['path'], qdict['script'])
679 683 out = await run_script_async(script=script,
680   - args=question['args'],
681   - stdin=question['stdin'])
682   - question.update(out)
  684 + args=qdict['args'],
  685 + stdin=qdict['stdin'])
  686 + qdict.update(out)
683 687  
684   - # Get class for this question type
685   - try:
686   - qclass = self._types[question['type']]
687   - except KeyError:
688   - logger.error('Invalid type "%s" in "%s"',
689   - question['type'], question['ref'])
690   - raise
691   -
692   - # Finally create an instance of Question()
693   - try:
694   - qinstance = qclass(QDict(question))
695   - except QuestionException:
696   - logger.error('Error generating question "%s". See "%s/%s"',
697   - question['ref'],
698   - question['path'],
699   - question['filename'])
700   - raise
701   -
702   - return qinstance
  688 + question = question_from(qdict) # returns a Question instance
  689 + question.gen()
  690 + return question
703 691  
704 692 # ------------------------------------------------------------------------
705 693 def generate(self) -> Question:
... ...
aprendizations/serve.py
... ... @@ -7,7 +7,7 @@ Webserver
7 7 import asyncio
8 8 import base64
9 9 import functools
10   -import logging.config
  10 +import logging
11 11 import mimetypes
12 12 from os.path import join, dirname, expanduser
13 13 import signal
... ... @@ -16,6 +16,8 @@ from typing import List, Optional, Union
16 16 import uuid
17 17  
18 18 # third party libraries
  19 +import tornado.httpserver
  20 +import tornado.ioloop
19 21 import tornado.web
20 22 from tornado.escape import to_unicode
21 23  
... ... @@ -89,11 +91,14 @@ class BaseHandler(tornado.web.RequestHandler):
89 91 def get_current_user(self):
90 92 '''called on every method decorated with @tornado.web.authenticated'''
91 93 user_cookie = self.get_secure_cookie('aprendizations_user')
  94 + counter_cookie = self.get_secure_cookie('counter')
92 95 if user_cookie is not None:
93 96 uid = user_cookie.decode('utf-8')
94   - counter = self.get_secure_cookie('counter').decode('utf-8')
95   - if counter == str(self.learn.get_login_counter(uid)):
96   - return uid
  97 +
  98 + if counter_cookie is not None:
  99 + counter = counter_cookie.decode('utf-8')
  100 + if counter == str(self.learn.get_login_counter(uid)):
  101 + return uid
97 102 return None
98 103  
99 104  
... ... @@ -141,7 +146,7 @@ class LoginHandler(BaseHandler):
141 146 Perform authentication and redirects to application if successful
142 147 '''
143 148  
144   - userid = self.get_body_argument('uid').lstrip('l')
  149 + userid = (self.get_body_argument('uid') or '').lstrip('l')
145 150 passwd = self.get_body_argument('pw')
146 151  
147 152 login_ok = await self.learn.login(userid, passwd)
... ... @@ -310,22 +315,19 @@ class FileHandler(BaseHandler):
310 315 uid = self.current_user
311 316 public_dir = self.learn.get_current_public_dir(uid)
312 317 filepath = expanduser(join(public_dir, filename))
313   - content_type = mimetypes.guess_type(filename)[0]
314 318  
315 319 try:
316 320 with open(filepath, 'rb') as file:
317 321 data = file.read()
318   - except FileNotFoundError:
319   - logger.error('File not found: %s', filepath)
320   - except PermissionError:
321   - logger.error('No permission: %s', filepath)
322   - except Exception:
  322 + except OSError:
323 323 logger.error('Error reading: %s', filepath)
324 324 raise
325   - else:
  325 +
  326 + content_type = mimetypes.guess_type(filename)[0]
  327 + if content_type is not None:
326 328 self.set_header("Content-Type", content_type)
327   - self.write(data)
328   - await self.flush()
  329 + self.write(data)
  330 + await self.flush()
329 331  
330 332  
331 333 # ============================================================================
... ...
aprendizations/student.py
... ... @@ -81,32 +81,32 @@ class StudentState():
81 81 self.topic_sequence = self._recommend_sequence(topics)
82 82  
83 83 # ------------------------------------------------------------------------
84   - async def start_topic(self, topic: str) -> None:
  84 + async def start_topic(self, topic_ref: str) -> None:
85 85 '''
86 86 Start a new topic.
87 87 questions: list of generated questions to do in the given topic
88 88 current_question: the current question to be presented
89 89 '''
90 90  
91   - logger.debug('start topic "%s"', topic)
  91 + logger.debug('start topic "%s"', topic_ref)
92 92  
93 93 # avoid regenerating questions in the middle of the current topic
94   - if self.current_topic == topic and self.uid != '0':
  94 + if self.current_topic == topic_ref and self.uid != '0':
95 95 logger.info('Restarting current topic is not allowed.')
96 96 return
97 97  
98 98 # do not allow locked topics
99   - if self.is_locked(topic) and self.uid != '0':
100   - logger.debug('is locked "%s"', topic)
  99 + if self.is_locked(topic_ref) and self.uid != '0':
  100 + logger.debug('is locked "%s"', topic_ref)
101 101 return
102 102  
103 103 self.previous_topic: Optional[str] = None
104 104  
105 105 # choose k questions
106   - self.current_topic = topic
  106 + self.current_topic = topic_ref
107 107 self.correct_answers = 0
108 108 self.wrong_answers = 0
109   - topic = self.deps.nodes[topic]
  109 + topic = self.deps.nodes[topic_ref]
110 110 k = topic['choose']
111 111 if topic['shuffle_questions']:
112 112 questions = random.sample(topic['questions'], k=k)
... ...
aprendizations/templates/rankings.html
... ... @@ -69,27 +69,23 @@
69 69 </thead>
70 70 <tbody>
71 71 {% for i,r in enumerate(rankings) %}
72   - {% if r[0] == uid %}
73   - <tr class="table-primary"> <!-- this is me -->
74   - {% else %}
75   - <tr>
76   - {% end %}
77   - <td class="text-center"> <!-- rank -->
78   - <strong>
79   - {{'<i class="fas fa-crown fa-2x text-warning"></i>' if i==0 else i+1}}
80   - </strong>
81   - </td>
82   - <td> <!-- student name -->
83   - {{ ' '.join(r[1].split()[n] for n in (0,-1)) }}
84   - &nbsp;
85   - {{ '<i class="far fa-thumbs-up text-success" title="Mais de 75% de respostas correctas"></i>' if r[3] > 0.75 else '' }}
86   - </td>
87   - <td> <!-- progress -->
88   - <div class="progress">
89   - <div class="progress-bar" role="progressbar" style="width: {{100*r[2]}}%;" aria-valuenow="{{round(100*r[2])}}%" aria-valuemin="0" aria-valuemax="100">{{round(100*r[2])}}%</div>
90   - </div>
91   - </td>
92   - </tr>
  72 + <tr class="{{ 'table-warning' if r[0] == uid else '' }}">
  73 + <td class="text-center"> <!-- rank -->
  74 + <strong>
  75 + {{ '<i class="fas fa-crown fa-2x text-warning"></i>' if i==0 else i+1 }}
  76 + </strong>
  77 + </td>
  78 + <td> <!-- student name -->
  79 + {{ ' '.join(r[1].split()[n] for n in (0,-1)) }}
  80 + &nbsp;
  81 + {{ '<i class="far fa-thumbs-up text-success" title="Mais de 75% de respostas correctas"></i>' if r[3] > 0.75 else '' }}
  82 + </td>
  83 + <td> <!-- progress -->
  84 + <div class="progress">
  85 + <div class="progress-bar" role="progressbar" style="width: {{100*r[2]}}%;" aria-valuenow="{{round(100*r[2])}}%" aria-valuemin="0" aria-valuemax="100">{{round(100*r[2])}}%</div>
  86 + </div>
  87 + </td>
  88 + </tr>
93 89 {% end %}
94 90 </tbody>
95 91 </table>
... ...
aprendizations/tools.py
... ... @@ -228,7 +228,7 @@ async def run_script_async(script: str,
228 228 )
229 229  
230 230 try:
231   - stdout, stderr = await asyncio.wait_for(
  231 + stdout, _ = await asyncio.wait_for(
232 232 p.communicate(input=stdin.encode('utf-8')),
233 233 timeout=timeout
234 234 )
... ...
package-lock.json
1 1 {
  2 + "name": "aprendizations",
  3 + "lockfileVersion": 2,
2 4 "requires": true,
3   - "lockfileVersion": 1,
  5 + "packages": {
  6 + "": {
  7 + "dependencies": {
  8 + "@fortawesome/fontawesome-free": "^5.15.2",
  9 + "codemirror": "^5.59.1",
  10 + "mdbootstrap": "^4.19.2"
  11 + }
  12 + },
  13 + "node_modules/@fortawesome/fontawesome-free": {
  14 + "version": "5.15.3",
  15 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.3.tgz",
  16 + "integrity": "sha512-rFnSUN/QOtnOAgqFRooTA3H57JLDm0QEG/jPdk+tLQNL/eWd+Aok8g3qCI+Q1xuDPWpGW/i9JySpJVsq8Q0s9w==",
  17 + "hasInstallScript": true,
  18 + "engines": {
  19 + "node": ">=6"
  20 + }
  21 + },
  22 + "node_modules/codemirror": {
  23 + "version": "5.61.1",
  24 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.61.1.tgz",
  25 + "integrity": "sha512-+D1NZjAucuzE93vJGbAaXzvoBHwp9nJZWWWF9utjv25+5AZUiah6CIlfb4ikG4MoDsFsCG8niiJH5++OO2LgIQ=="
  26 + },
  27 + "node_modules/mdbootstrap": {
  28 + "version": "4.19.2",
  29 + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.19.2.tgz",
  30 + "integrity": "sha512-a+LwPflYRYwlmYKTvftW0X7SfOMrRZ02qZjrssNko1lPU/HR5JRFc1uwa3Dmmw+6TwsYH760waqdghBFrucpOw=="
  31 + }
  32 + },
4 33 "dependencies": {
5 34 "@fortawesome/fontawesome-free": {
6   - "version": "5.15.2",
7   - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.2.tgz",
8   - "integrity": "sha512-7l/AX41m609L/EXI9EKH3Vs3v0iA8tKlIOGtw+kgcoanI7p+e4I4GYLqW3UXWiTnjSFymKSmTTPKYrivzbxxqA=="
9   - },
10   - "animate.css": {
11   - "version": "4.1.1",
12   - "resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
13   - "integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ=="
  35 + "version": "5.15.3",
  36 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.3.tgz",
  37 + "integrity": "sha512-rFnSUN/QOtnOAgqFRooTA3H57JLDm0QEG/jPdk+tLQNL/eWd+Aok8g3qCI+Q1xuDPWpGW/i9JySpJVsq8Q0s9w=="
14 38 },
15 39 "codemirror": {
16   - "version": "5.59.1",
17   - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.59.1.tgz",
18   - "integrity": "sha512-d0SSW/PCCD4LoSCBPdnP0BzmZB1v3emomCUtVlIWgZHJ06yVeBOvBtOH7vYz707pfAvEeWbO9aP6akh8vl1V3w=="
  40 + "version": "5.61.1",
  41 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.61.1.tgz",
  42 + "integrity": "sha512-+D1NZjAucuzE93vJGbAaXzvoBHwp9nJZWWWF9utjv25+5AZUiah6CIlfb4ikG4MoDsFsCG8niiJH5++OO2LgIQ=="
19 43 },
20 44 "mdbootstrap": {
21 45 "version": "4.19.2",
... ...
setup.py
... ... @@ -18,13 +18,13 @@ setup(
18 18 url="https://git.xdi.uevora.pt/mjsb/aprendizations.git",
19 19 packages=find_packages(),
20 20 include_package_data=True, # install files from MANIFEST.in
21   - python_requires='>=3.7.*',
  21 + python_requires='>=3.8.*',
22 22 install_requires=[
23 23 'tornado>=6.0',
24 24 'mistune',
25 25 'pyyaml>=5.1',
26 26 'pygments',
27   - 'sqlalchemy',
  27 + 'sqlalchemy<1.4',
28 28 'bcrypt>=3.1',
29 29 'networkx>=2.4'
30 30 ],
... ...