Commit 443a1eead78985ecc296b4c301921bea4c2992cb

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

Update to latest sqlalchemy 1.4, etc.

Use bootstrap instead of material themed version.
Lot's of small changes and fixes.
1 1
2 # BUGS 2 # BUGS
3 3
4 -- nao esta a seguir o max_tries definido no ficheiro de dependencias. 4 +- se na especificacao de um curso, a referencia do topico nao existir como directorio, rebenta.
  5 +- internal server error ao fazer logout no macos python3.8
  6 +- GET can get filtered by browser cache
  7 +- topicos chapter devem ser automaticamente completos assim que as dependencias
  8 + são satisfeitas. Nao devia ser necessario (ou possivel?) clicar neles.
  9 +- topicos do tipo learn deviam por defeito nao ser randomizados e assumir
  10 + ficheiros `learn.yaml`.
  11 +- internal server error 500... experimentar cenario: aluno tem login efectuado,
  12 + prof muda pw e faz login/logout. aluno obtem erro 500.
  13 +- radio sem options rebenta com aprendizations --check
  14 +- chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos
  15 + pensam que já terminaram e não conseguem progredir por causa das
  16 + dependencias.
  17 +- if topic deps on invalid ref terminates server with "Unknown error".
  18 +- warning nos topics que não são usados em nenhum curso
  19 +- nao esta a seguir o `max_tries` definido no ficheiro de dependencias.
5 - devia mostrar timeout para o aluno saber a razao. 20 - devia mostrar timeout para o aluno saber a razao.
6 - permitir configuracao para escolher entre static files locais ou remotos 21 - permitir configuracao para escolher entre static files locais ou remotos
7 - shift-enter não está a funcionar 22 - shift-enter não está a funcionar
@@ -9,67 +24,93 @@ @@ -9,67 +24,93 @@
9 24
10 # TODO 25 # TODO
11 26
12 -- alterar tabelas para incluir email de recuperacao de password (e outros avisos)  
13 -- registar last_seen e remover os antigos de cada vez que houver um login. 27 +- shuffle das perguntas dentro de um topico
  28 +- alterar tabelas para incluir email de recuperacao de password (e outros
  29 + avisos)
  30 +- registar `last_seen` e remover os antigos de cada vez que houver um login.
14 - indicar qtos topicos faltam (>=50%) para terminar o curso. 31 - indicar qtos topicos faltam (>=50%) para terminar o curso.
15 - ao fim de 3 tentativas com password errada, envia email com nova password. 32 - ao fim de 3 tentativas com password errada, envia email com nova password.
16 -- mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias.  
17 -- mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos. 33 +- mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo
  34 + expande as dependencias.
  35 +- mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado
  36 + topicos.
18 - botão não sei... 37 - botão não sei...
19 - mostrar icon "loading..." enquanto está a corrigir uma pergunta. 38 - mostrar icon "loading..." enquanto está a corrigir uma pergunta.
20 - session management. close after inactive time. 39 - session management. close after inactive time.
21 - radio e checkboxes, aceitar numeros como seleccao das opcoes. 40 - radio e checkboxes, aceitar numeros como seleccao das opcoes.
22 -- reload das perguntas enquanto online. ver signal em http://stackabuse.com/python-async-await-tutorial/ 41 +- reload das perguntas enquanto online. ver signal em
  42 + [](http://stackabuse.com/python-async-await-tutorial/)
23 - tabela de progresso de todos os alunos por topico. 43 - tabela de progresso de todos os alunos por topico.
24 - tabela com perguntas / quantidade de respostas certas/erradas. 44 - tabela com perguntas / quantidade de respostas certas/erradas.
25 - tabela com topicos / quantidade de estrelas. 45 - tabela com topicos / quantidade de estrelas.
26 - pymips: activar/desactivar instruções 46 - pymips: activar/desactivar instruções
27 - titulos das perguntas não suportam markdown. 47 - titulos das perguntas não suportam markdown.
28 -- pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta. 48 +- pagina report que permita ver tabela alunos/topicos, estatisticas perguntas
  49 + mais falhadas, tempo médio por pergunta.
29 - normalizar com perguntations. 50 - normalizar com perguntations.
30 51
31 # FIXED 52 # FIXED
32 53
33 -- templates question-*.html tem input hidden question_ref que não é usado. remover? 54 +- templates question-*.html tem input hidden question_ref que não é usado.
  55 + remover?
34 - goals se forem do tipo chapter deve importar todas as dependencias do chapter. 56 - goals se forem do tipo chapter deve importar todas as dependencias do chapter.
35 -- initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)  
36 -- dependencias que não são goals de um curso, só devem aparecer se ainda não tiverem sido feitas. 57 +- initdb da integrity error se no mesmo comando existirem alunos repetidos
  58 + (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
  59 +- dependencias que não são goals de um curso, só devem aparecer se ainda não
  60 + tiverem sido feitas.
37 - ir para inicio da pagina quando le nova pergunta. 61 - ir para inicio da pagina quando le nova pergunta.
38 - CRITICAL nao esta a guardar o progresso na base de dados. 62 - CRITICAL nao esta a guardar o progresso na base de dados.
39 - mesma ref no mesmo ficheiro não é detectado. 63 - mesma ref no mesmo ficheiro não é detectado.
40 - enter nas respostas mostra json 64 - enter nas respostas mostra json
41 -- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande) 65 +- apos clicar no botao responder, inactivar o input (importante quando o tempo
  66 + de correcção é grande)
42 - double click submits twice. 67 - double click submits twice.
43 -- 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. 68 +- checkbox devia ter correct no intervalo [0,1] tal como radio. em caso de
  69 + desconto a correccção faz 2*x-1. isto permite a mesma semantica nos dois
  70 + tipos de perguntas.
44 - marking all options right in a radio question breaks! 71 - marking all options right in a radio question breaks!
45 - implementar servidor http com redirect para https. 72 - implementar servidor http com redirect para https.
46 - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question. 73 - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.
47 - click numa opcao checkbox fora da checkbox+label não está a funcionar. 74 - click numa opcao checkbox fora da checkbox+label 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 -- QFactory.generate() devia fazer run da gen_async, ou remover. 75 +- mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam
  76 + centradas em toda a coluna mas apenas na largura do parágrafo.
  77 +- QFactory.generate() devia fazer run da `gen_async,` ou remover.
50 - classificacoes so devia mostrar os que ja fizeram alguma coisa 78 - classificacoes so devia mostrar os que ja fizeram alguma coisa
51 - impedir que quando students.db não é encontrado, crie um ficheiro vazio. 79 - impedir que quando students.db não é encontrado, crie um ficheiro vazio.
52 -- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.  
53 -- 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. 80 +- permite definir goal, mas nao verifica se esta no grafo. rebenta no
  81 + `start_topic`.
  82 +- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as
  83 + imagengs porque o `current_topic` passa a None antes de carregar no botao
  84 + continuar. O caminho é prefix+None e dá erro.
54 - caixas com os cursos não se ajustam bem com ecran estreito. 85 - caixas com os cursos não se ajustam bem com ecran estreito.
55 -- obter rankings por curso GET course=course_id  
56 -- no curso de linear algebra, as perguntas estao shuffled, mas nao deviam estar... nao esta a obedecer a keyword shuffle. 86 +- obter rankings por curso `GET course=course_id`
  87 +- no curso de linear algebra, as perguntas estao shuffled, mas nao deviam
  88 + estar... nao esta a obedecer a keyword shuffle.
57 - menu nao mostra as opcoes correctamente 89 - menu nao mostra as opcoes correctamente
58 - finish topic vai para a lista de cursos. devia ficar no mesmo curso. 90 - finish topic vai para a lista de cursos. devia ficar no mesmo curso.
59 - mathjax nao esta a correr sobre o titulo. 91 - mathjax nao esta a correr sobre o titulo.
60 - forgetting factor is hardcoded in student.py 92 - forgetting factor is hardcoded in student.py
61 - add aprendizatons --version 93 - add aprendizatons --version
62 -- 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 /. 94 +- se aluno abre dois tabs no browser, conseque navegar em simultaneo para
  95 + perguntas diferentes. quando submete uma delas dá asneira. Tem de haver um
  96 + campo hidden que tenha um céodigo único que indique qual a pergunta. do lado
  97 + do servidor apnas há o codigo da pergunta corrente, se forem diferentes faz
  98 + redirect para /.
63 - nos topicos learn.yaml, qd falha acrescenta no fim. nao faz sentido. 99 - nos topicos learn.yaml, qd falha acrescenta no fim. nao faz sentido.
64 -- não esta a fazer render correcto de tabelas nas opcoes checkbox. e.g. information-theory/source-coding-theory/block-codes 100 +- não esta a fazer render correcto de tabelas nas opcoes checkbox. e.g.
  101 + `information-theory/source-coding-theory/block-codes`
65 - max tries nas perguntas. 102 - max tries nas perguntas.
66 - mostrar feedback/solucoes quando acerta, ou excede max tries. 103 - mostrar feedback/solucoes quando acerta, ou excede max tries.
67 -- 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. 104 +- quando se pressiona "responde" rapido (enquanto a animacao dura), a pergunta
  105 + passa para a seguinte sem haver o correspondente redraw, ou seja a proxima
  106 + resposta nao é a da pergunta mostrada.
68 - botao para mostrar a solução quando se acerta. 107 - botao para mostrar a solução quando se acerta.
69 - não está a guardar o resultado no final do topico 108 - não está a guardar o resultado no final do topico
70 -- esta a permitir 2 logins em simultaneo do mesmo user. fica tudo baralhado se mxerem em simultaneo... 109 +- esta a permitir 2 logins em simultaneo do mesmo user. fica tudo baralhado se
  110 + mxerem em simultaneo...
71 - errar no ultimo topico nao mostra solucao? 111 - errar no ultimo topico nao mostra solucao?
72 -- quando a pergunta devolve comments, este é apresentado, mas fica persistente nas tentativas seguintes. devia ser limpo apos a segunda submissao. 112 +- quando a pergunta devolve comments, este é apresentado, mas fica persistente
  113 + nas tentativas seguintes. devia ser limpo apos a segunda submissao.
73 - na definicao dos topicos, indicar: 114 - na definicao dos topicos, indicar:
74 "file: questions.yaml" (default questions.yaml) 115 "file: questions.yaml" (default questions.yaml)
75 "shuffle: True/False" (default False) 116 "shuffle: True/False" (default False)
@@ -84,9 +125,11 @@ @@ -84,9 +125,11 @@
84 - each topic only loads a sample of K questions (max) in random order. 125 - each topic only loads a sample of K questions (max) in random order.
85 - change password modal nao aparece no ipad (safari e firefox) 126 - change password modal nao aparece no ipad (safari e firefox)
86 - detect questions in questions.yaml without ref -> error ou generate default. 127 - detect questions in questions.yaml without ref -> error ou generate default.
87 -- generators e correct scripts que durem muito tempo bloqueiam o eventloop do tornado. 128 +- generators e correct scripts que durem muito tempo bloqueiam o eventloop do
  129 + tornado.
88 - servir imagens/ficheiros. 130 - servir imagens/ficheiros.
89 -- radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma selecção aleatoria destas (so com 1 certa). 131 +- radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma
  132 + selecção aleatoria destas (so com 1 certa).
90 - checkbox: cada opção pode ser uma dupla (certo, errado) sendo escolhida uma aleatória. 133 - checkbox: cada opção pode ser uma dupla (certo, errado) sendo escolhida uma aleatória.
91 - async/threadpool no bcrypt do initdb. 134 - async/threadpool no bcrypt do initdb.
92 - numero de estrelas depende da proporcao entre certas e erradas. 135 - numero de estrelas depende da proporcao entre certas e erradas.
@@ -97,10 +140,12 @@ @@ -97,10 +140,12 @@
97 - remover learn.css uma vez que nao é usado em lado nenhum? 140 - remover learn.css uma vez que nao é usado em lado nenhum?
98 - check if user already logged in 141 - check if user already logged in
99 - mover javascript para ficheiros externos e carregar com script defer src 142 - mover javascript para ficheiros externos e carregar com script defer src
100 -- implementar xsrf. Ver [http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection]()  
101 -- se refs de um topic estao invalidos, nao carrega esse topico. devia haver um error nos logs a indicar qual o ref invalido. 143 +- implementar xsrf. Ver [](http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection)
  144 +- se refs de um topic estao invalidos, nao carrega esse topico. devia haver um
  145 + error nos logs a indicar qual o ref invalido.
102 - link directo para topico nao valida se topico esta unlocked. 146 - link directo para topico nao valida se topico esta unlocked.
103 -- templates not working: quesntion-information, question-warning (remove all informative panels??) 147 +- templates not working: quesntion-information, question-warning (remove all
  148 + informative panels??)
104 - enderecos errados dao internal error. 149 - enderecos errados dao internal error.
105 - barra de progresso nao está visível. 150 - barra de progresso nao está visível.
106 - tabs em textarea nao funcionam correctamente (insere 1 espaco em vez de 4) 151 - tabs em textarea nao funcionam correctamente (insere 1 espaco em vez de 4)
@@ -110,14 +155,15 @@ @@ -110,14 +155,15 @@
110 - animação no final de cada topico para se perceber a transição 155 - animação no final de cada topico para se perceber a transição
111 - "<" is not escaped in markdown. 156 - "<" is not escaped in markdown.
112 - Está a mostrar a solução em 'comments'!!! 157 - Está a mostrar a solução em 'comments'!!!
113 -- database: answers não tem referencia para o topico, so para question_ref 158 +- database: answers não tem referencia para o topico, so para `question_ref`
114 - melhorar markdown das tabelas. 159 - melhorar markdown das tabelas.
115 - gravar evolucao na bd no final de cada topico. 160 - gravar evolucao na bd no final de cada topico.
116 - submeter questoes radio, da erro se nao escolher nenhuma opção. 161 - submeter questoes radio, da erro se nao escolher nenhuma opção.
117 - indentação da primeira linha de código não funciona. 162 - indentação da primeira linha de código não funciona.
118 - markdown com o mistune. 163 - markdown com o mistune.
119 - change password in maintopics.html, falta menu para lançar modal 164 - change password in maintopics.html, falta menu para lançar modal
120 -- ver documentacao de migracao para networkx 2.0 https://networkx.github.io/documentation/stable/release/migration_guide_from_1.x_to_2.0.html 165 +- ver documentacao de migracao para networkx 2.0
  166 + [](https://networkx.github.io/documentation/stable/release/migration_guide_from_1.x_to_2.0.html)
121 - script para adicionar users/reset passwords. 167 - script para adicionar users/reset passwords.
122 - os topicos locked devem estar inactivos no sidebar. 168 - os topicos locked devem estar inactivos no sidebar.
123 - enter faz GET /question, que responde com json no ecran. (solution: disabled enter) 169 - enter faz GET /question, que responde com json no ecran. (solution: disabled enter)
@@ -126,7 +172,8 @@ @@ -126,7 +172,8 @@
126 - indicar o topico actual no sidebar 172 - indicar o topico actual no sidebar
127 - reload da página rebenta o estado. 173 - reload da página rebenta o estado.
128 - text deve mostrar no html os valores iniciais de ans, se existir 174 - text deve mostrar no html os valores iniciais de ans, se existir
129 -- nao permite perguntas repetidas. iterar questions da configuracao em vez das do ficheiro. ver app.py linha 223. 175 +- nao permite perguntas repetidas. iterar questions da configuracao em vez das
  176 + do ficheiro. ver app.py linha 223.
130 - level depender do numero de respostas correctas 177 - level depender do numero de respostas correctas
131 - pymips a funcionar 178 - pymips a funcionar
132 - logs mostram que está a gerar cada pergunta 2 vezes...?? 179 - logs mostram que está a gerar cada pergunta 2 vezes...??
@@ -138,15 +185,18 @@ @@ -138,15 +185,18 @@
138 - se students.db não existe, rebenta. 185 - se students.db não existe, rebenta.
139 - não entra à primeira 186 - não entra à primeira
140 - configuração e linha de comando. 187 - configuração e linha de comando.
141 -- o browser é redireccionado para /question em vez de fazer um post?? quando se pressiona enter numa caixa text edit. 188 +- o browser é redireccionado para /question em vez de fazer um post?? quando se
  189 + pressiona enter numa caixa text edit.
142 - load/save the knowledge state of the student 190 - load/save the knowledge state of the student
143 - servir ficheiros de public temporariamente 191 - servir ficheiros de public temporariamente
144 - path dos generators scripts mal construido 192 - path dos generators scripts mal construido
145 - questions hardcoded in LearnApp. 193 - questions hardcoded in LearnApp.
146 - Factory para cada pergunta individual em vez de pool 194 - Factory para cada pergunta individual em vez de pool
147 -- implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona, enter submete. 195 +- implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona,
  196 + enter submete.
148 - logging 197 - logging
149 -- textarea tem codigo para preencher o texto, mas ja não é necessário porque pergunta não é reloaded. 198 +- textarea tem codigo para preencher o texto, mas ja não é necessário porque
  199 + pergunta não é reloaded.
150 - gravar answers -> db 200 - gravar answers -> db
151 - como gerar key para secure cookie. 201 - como gerar key para secure cookie.
152 - https. certificados selfsigned, no-ip nao suporta certificados 202 - https. certificados selfsigned, no-ip nao suporta certificados
@@ -161,4 +211,5 @@ @@ -161,4 +211,5 @@
161 - clicar texto selecciona checkboxes/radio. 211 - clicar texto selecciona checkboxes/radio.
162 - focar text/textarea 212 - focar text/textarea
163 - implementar template base das perguntas base e estender para cada tipo. 213 - implementar template base das perguntas base e estender para cada tipo.
164 -- submissão com enter em perguntas text faz get? provavelmente está a fazer o submit do form em vez de ir pelo ajax. 214 +- submissão com enter em perguntas text faz get? provavelmente está a fazer o
  215 + submit do form em vez de ir pelo ajax.
1 # Questions 1 # Questions
2 2
3 -Questions are saved in files in the [YAML](http://www.yaml.org/start.html) format. Each file contains a list of questions like 3 +Questions are saved in files in the [YAML](http://www.yaml.org/start.html)
  4 +format. Each file contains a list of questions like
4 5
5 ```yaml 6 ```yaml
6 - type: radio 7 - type: radio
@@ -12,10 +13,11 @@ Questions are saved in files in the [YAML](http://www.yaml.org/start.html) forma @@ -12,10 +13,11 @@ Questions are saved in files in the [YAML](http://www.yaml.org/start.html) forma
12 ... 13 ...
13 ``` 14 ```
14 15
15 -where each question is specified in a dictionary.  
16 -The `type` key is mandatory and specifies the type of question (multiple choice, text, etc).  
17 -The other keys available will depend on the type of question.  
18 -The field `ref` is not strictly required but still recommended, if not defined it will default to a string with the filename and the question index, e.g., `questions.yaml:12`. 16 +where each question is specified in a dictionary. The `type` key is mandatory
  17 +and specifies the type of question (multiple choice, text, etc). The other
  18 +keys available will depend on the type of question. The field `ref` is not
  19 +strictly required but still recommended, if not defined it will default to a
  20 +string with the filename and the question index, e.g., `questions.yaml:12`.
19 21
20 The following types of questions are supported: 22 The following types of questions are supported:
21 23
@@ -34,15 +36,16 @@ type | kind of answer @@ -34,15 +36,16 @@ type | kind of answer
34 36
35 ### radio 37 ### radio
36 38
37 -Only one option can be selected as the answer. If no option is selected, the question is considered unanswered. 39 +Only one option can be selected as the answer. If no option is selected, the
  40 +question is considered unanswered.
38 41
39 The general format is 42 The general format is
40 43
41 ```yaml 44 ```yaml
42 - type: radio 45 - type: radio
43 - ref: question_reference 46 + ref: question_reference
44 title: My first question 47 title: My first question
45 - text: | 48 + text: |
46 Please select one option. 49 Please select one option.
47 options: 50 options:
48 - this one is the correct one 51 - this one is the correct one
@@ -54,30 +57,37 @@ The general format is @@ -54,30 +57,37 @@ The general format is
54 discount: yes # default: yes 57 discount: yes # default: yes
55 ``` 58 ```
56 59
57 -All fields are optional except `type` and `options`. `title` and `text` default to empty strings, `shuffle` and `discount` to `true`. 60 +All fields are optional except `type` and `options`. `title` and `text` default
  61 +to empty strings, `shuffle` and `discount` to `true`.
58 62
59 -The `correct` field can be used in multiple ways and in combination with `shuffle`, `discount` and `choose` fields: 63 +The `correct` field can be used in multiple ways and in combination with
  64 +`shuffle`, `discount` and `choose` fields:
60 65
61 -- if not present, the first option is considered correct (options are shuffled by default when presented to the student).  
62 -- it can be the index (0-based) of the correct option, e.g., `correct: 0` for the first option.  
63 -- it can be a list of numbers between 0 and 1, e.g., `correct: [1, 0, 0]`. In this case, the first option is 100% correct while the others are 0%. If `discount: true` (the default), then the wrong ones will be penalized by $-1/(n-1)=-\tfrac{1}{2}$, where $n$ is the number of options. 66 +- if not present, the first option is considered correct (options are shuffled
  67 + by default when presented to the student).
  68 +- it can be the index (0-based) of the correct option, e.g., `correct: 0` for
  69 + the first option.
  70 +- it can be a list of numbers between 0 and 1, e.g., `correct: [1, 0, 0]`. In
  71 + this case, the first option is 100% correct while the others are 0%. If `discount: true` (the default), then the wrong ones will be penalized by $-1/(n-1)=-\tfrac{1}{2}$, where $n$ is the number of options.
64 - there can be more than one correct option in the list, which is then marked in the correct field, e.g. `correct: [1, 1, 0]`. In this case, one of the correct options will be randomly selected, and the remaining wrong ones appended. 72 - there can be more than one correct option in the list, which is then marked in the correct field, e.g. `correct: [1, 1, 0]`. In this case, one of the correct options will be randomly selected, and the remaining wrong ones appended.
65 - there can also be a long list of right and wrong options from which to build the question options. E.g. if `correct: [1,1,1,0,0,0,0]` and `choose: 3` is defined, then 1 correct option and 2 wrong ones are randomly selected from the list. 73 - there can also be a long list of right and wrong options from which to build the question options. E.g. if `correct: [1,1,1,0,0,0,0]` and `choose: 3` is defined, then 1 correct option and 2 wrong ones are randomly selected from the list.
66 - finally it's also possible to have a question that is *"not-completely-right"* or *"not-completely-wrong"*. This can be done using numbers between 0 and 1, e.g., `correct: [1, 0.3, 0]`. This practice is discouraged. 74 - finally it's also possible to have a question that is *"not-completely-right"* or *"not-completely-wrong"*. This can be done using numbers between 0 and 1, e.g., `correct: [1, 0.3, 0]`. This practice is discouraged.
67 75
68 -In some situations one may not want the options to be shuffled. In that case use `shuffle: false`. 76 +In some situations one may not want the options to be shuffled. In that case
  77 +use `shuffle: false`.
69 78
70 ### checkbox 79 ### checkbox
71 80
72 -Zero, one or multiple options can be selected. The question is always considered as answered, even if no options are selected. 81 +Zero, one or multiple options can be selected. The question is always
  82 +considered as answered, even if no options are selected.
73 83
74 -The simplest format is 84 +The simplest format is
75 85
76 ```yaml 86 ```yaml
77 - type: checkbox 87 - type: checkbox
78 ref: question_reference 88 ref: question_reference
79 title: My second question 89 title: My second question
80 - text: | 90 + text: |
81 Please mark the correct options. 91 Please mark the correct options.
82 options: 92 options:
83 - this one is correct 93 - this one is correct
@@ -89,16 +99,22 @@ The simplest format is @@ -89,16 +99,22 @@ The simplest format is
89 discount: yes # default: yes 99 discount: yes # default: yes
90 ``` 100 ```
91 101
92 -All fields are optional except `type` and `options`. `title` and `text` default to empty strings, `shuffle` and `discount` to `true` and `choose` to the total number of options. 102 +All fields are optional except `type` and `options`. `title` and `text` default
  103 +to empty strings, `shuffle` and `discount` to `true` and `choose` to the total
  104 +number of options.
93 105
94 -When correcting an answer, each correctly marked/unmarked option gets the corresponding value from the list `correct: [1, -1, 1]` and each wrong gets its symmetrical. So in the previous example, to have a completely right answer the checboxes should be: marked, unmarked, marked. 106 +When correcting an answer, each correctly marked/unmarked option gets the
  107 +corresponding value from the list `correct: [1, -1, 1]` and each wrong gets its
  108 +symmetrical. So in the previous example, to have a completely right answer the
  109 +checboxes should be: marked, unmarked, marked.
95 110
96 -If `discount: no` then wrong options are given a value of 0.  
97 -Options are shuffled by default. A smaller number of options may be randomly selected by setting the option `choose`. 111 +If `discount: no` then wrong options are given a value of 0. Options are
  112 +shuffled by default. A smaller number of options may be randomly selected by
  113 +setting the option `choose`.
98 114
99 -A more advanced format is to have two versions for each option, one right and one wrong.  
100 -One of the versions is randomly selected when the question is generated.  
101 -For example, 115 +A more advanced format is to have two versions for each option, one right and
  116 +one wrong. One of the versions is randomly selected when the question is
  117 +generated. For example,
102 118
103 ```yaml 119 ```yaml
104 options: 120 options:
@@ -108,11 +124,12 @@ For example, @@ -108,11 +124,12 @@ For example,
108 correct: [1, -1, -1] 124 correct: [1, -1, -1]
109 ``` 125 ```
110 126
111 -If the first version is selected then the corresponding `correct` value is used, otherwise if  
112 -the second version is selected, then the symmetrical value is used instead. 127 +If the first version is selected then the corresponding `correct` value is
  128 +used, otherwise if the second version is selected, then the symmetrical value
  129 +is used instead.
113 130
114 -This format is useful to write questions that are presented in different ways to different students.  
115 -It also minimizes solution memorization. Example: 131 +This format is useful to write questions that are presented in different ways
  132 +to different students. It also minimizes solution memorization. Example:
116 133
117 ```yaml 134 ```yaml
118 options: 135 options:
@@ -122,7 +139,8 @@ It also minimizes solution memorization. Example: @@ -122,7 +139,8 @@ It also minimizes solution memorization. Example:
122 139
123 ### text 140 ### text
124 141
125 -The answer is a single line of text. Just compare the answered text with the strings provided in a list of answers considered to be right. 142 +The answer is a single line of text. Just compare the answered text with the
  143 +strings provided in a list of answers considered to be right.
126 144
127 ```yaml 145 ```yaml
128 - type: text 146 - type: text
@@ -144,12 +162,14 @@ Similar to text, but answers are validated by a regular expression. @@ -144,12 +162,14 @@ Similar to text, but answers are validated by a regular expression.
144 correct: !regex '[wW]eek' # default: '$.^' always wrong 162 correct: !regex '[wW]eek' # default: '$.^' always wrong
145 ``` 163 ```
146 164
147 -The regular expression is in a string and must be prefixed by the keyword `!regex`. 165 +The regular expression is in a string and must be prefixed by the keyword
  166 +`!regex`.
148 167
149 ### numeric-interval 168 ### numeric-interval
150 169
151 -Similar to text, but expects an integer or floating point number.  
152 -The answer is converted to a float and is considered correct if the number is in the given closed interval. 170 +Similar to text, but expects an integer or floating point number. The answer
  171 +is converted to a float and is considered correct if the number is in the given
  172 +closed interval.
153 173
154 ```yaml 174 ```yaml
155 - type: numeric-interval 175 - type: numeric-interval
@@ -161,9 +181,10 @@ The answer is converted to a float and is considered correct if the number is in @@ -161,9 +181,10 @@ The answer is converted to a float and is considered correct if the number is in
161 181
162 ### textarea 182 ### textarea
163 183
164 -Provides a multiline textarea for the answer.  
165 -The answered text is sent to the standard input of an external program for grading.  
166 -The printed output to standard output of the program is parsed as YAML to get the grade and optional comments. 184 +Provides a multiline textarea for the answer. The answered text is sent to the
  185 +standard input of an external program for grading. The printed output to
  186 +standard output of the program is parsed as YAML to get the grade and optional
  187 +comments.
167 188
168 ```yaml 189 ```yaml
169 - type: textarea 190 - type: textarea
@@ -185,7 +206,6 @@ comments: Almost there @@ -185,7 +206,6 @@ comments: Almost there
185 206
186 It can also just print the grade as a single number. 207 It can also just print the grade as a single number.
187 208
188 -  
189 ### information, warning, alert and success 209 ### information, warning, alert and success
190 210
191 These are not really questions, but just provides information for the student. 211 These are not really questions, but just provides information for the student.
@@ -201,8 +221,8 @@ Grading these type of &quot;questions&quot; yields always correct. @@ -201,8 +221,8 @@ Grading these type of &quot;questions&quot; yields always correct.
201 221
202 ### generator 222 ### generator
203 223
204 -Questions can be generated by external programs instead of being defined directly.  
205 -This allows great flexibility, and allows each instance of a question to be always different. 224 +This allows great flexibility, and allows each instance of a question to be
  225 +always different.
206 226
207 ```yaml 227 ```yaml
208 type: generator 228 type: generator
@@ -211,16 +231,18 @@ script: executable_program @@ -211,16 +231,18 @@ script: executable_program
211 arg: 10,20 231 arg: 10,20
212 ``` 232 ```
213 233
214 -A generator is an external program that generates a question dynamically. 234 +A generator is an external program that generates a question dynamically.
215 In the example above, the program to be run is `executable_program`. 235 In the example above, the program to be run is `executable_program`.
216 The `arg` is sent to the standard input of the `executable_program`. 236 The `arg` is sent to the standard input of the `executable_program`.
217 237
218 -Questions should be printed to the stdout in YAML format, similarly to how they are defined above (but without the list dash).  
219 -The printed question is then parsed to a dictionary which is then used to update the question.  
220 -The `type` is redefined from generator to something else and the other fields are also updated. 238 +Questions should be printed to the stdout in YAML format, similarly to how they
  239 +are defined above (but without the list dash). The printed question is then
  240 +parsed to a dictionary which is then used to update the question. The `type`
  241 +is redefined from generator to something else and the other fields are also
  242 +updated.
221 243
222 -A generator can be any executable program (written in any language) that prints to the standard output.  
223 -Example of a generator written in python: 244 +A generator can be any executable program (written in any language) that prints
  245 +to the standard output. Example of a generator written in python:
224 246
225 ```python 247 ```python
226 #!/usr/bin/env python3 248 #!/usr/bin/env python3
@@ -234,7 +256,8 @@ a,b = (int(n) for n in arg.split(&#39;,&#39;)) @@ -234,7 +256,8 @@ a,b = (int(n) for n in arg.split(&#39;,&#39;))
234 q = fr''' 256 q = fr'''
235 type: checkbox 257 type: checkbox
236 text: | 258 text: |
237 - Indique quais das seguintes adições resultam em overflow quando se considera a adição de números com sinal (complemento para 2) em registos de 8 bits. 259 + Indique quais das seguintes adições resultam em overflow quando se considera
  260 + a adição de números com sinal (complemento para 2) em registos de 8 bits.
238 261
239 Os números foram gerados aleatoriamente no intervalo de {a} a {b}. 262 Os números foram gerados aleatoriamente no intervalo de {a} a {b}.
240 options: 263 options:
@@ -254,14 +277,14 @@ print(q) @@ -254,14 +277,14 @@ print(q)
254 277
255 A generator cannot generate another generator, only real questions are acceptable. 278 A generator cannot generate another generator, only real questions are acceptable.
256 279
257 -# Writing text 280 +## Writing text
258 281
259 The text in the questions is interpreted as markdown with support for LaTeX formulas. 282 The text in the questions is interpreted as markdown with support for LaTeX formulas.
260 The best way to write text is to use indentation like this: 283 The best way to write text is to use indentation like this:
261 284
262 ```yaml 285 ```yaml
263 text: | 286 text: |
264 - Yes. this is ok: If not indented, "Yes" would be a boolean 287 + Yes. this is ok: If not indented, "Yes" would be a boolean
265 and colon would be interpreted as a dictionary key. 288 and colon would be interpreted as a dictionary key.
266 289
267 Images placed in the `public` subdirectory are accessible by 290 Images placed in the `public` subdirectory are accessible by
1 # Getting Started 1 # Getting Started
2 2
  3 +Latest review: 2021-07-08
3 4
4 ## Installation 5 ## Installation
5 6
6 To complete the installation we will need to perform the following steps: 7 To complete the installation we will need to perform the following steps:
7 8
8 -1. install python3.7, pip and npm  
9 -1. download aprendizations from the repository  
10 -1. install javascript libraries (with npm)  
11 -1. install aprendizations (with pip)  
12 -1. generate SSL certificates  
13 -1. configure the firewall (optional) 9 +1. install python3, pip and npm
  10 +2. download aprendizations from the repository
  11 +3. install javascript libraries (with npm)
  12 +4. install aprendizations (with pip)
  13 +5. generate SSL certificates
  14 +6. configure the firewall (optional)
14 15
15 To use the software we need to: 16 To use the software we need to:
16 17
17 1. initialize database 18 1. initialize database
18 -1. go to the demo directory (or an existing course)  
19 -1. run `aprendizations demo.yaml` 19 +2. go to the demo directory (or an existing course)
  20 +3. run `aprendizations demo.yaml`
20 21
21 Each of these steps is explained below. 22 Each of these steps is explained below.
22 23
23 -### Install python3.7 with sqlite3 support and npm 24 +### Install python3 with sqlite3 support and npm
24 25
25 -Python can be installed either from the system package management or compiled from sources. 26 +Python can be installed either from the system package management or compiled
  27 +from sources.
26 28
27 #### Installing from the system package manager 29 #### Installing from the system package manager
28 30
29 ```sh 31 ```sh
30 -sudo apt install python3.7 npm # Linux (Ubuntu)  
31 -sudo pkg install python37 py37-sqlite3 npm # FreeBSD  
32 -sudo port install python37 npm6 # MacOS 32 +sudo pkg install python3 npm # FreeBSD
  33 +sudo apt install python3 npm # Linux (Ubuntu)
  34 +sudo port install python38 npm7 # MacOS
33 ``` 35 ```
34 36
35 -#### Installing from source  
36 - 37 +In FreeBSD also install `py3X-sqlite3` where `X` is the python version.
  38 +
  39 +#### Installing from source (outdated)
  40 +
37 Make sure that the build tools and libraries are installed: 41 Make sure that the build tools and libraries are installed:
38 42
39 ```sh 43 ```sh
40 # Ubuntu: 44 # Ubuntu:
41 -sudo apt install build-essential libssl-dev zlib1g-dev libncurses5-dev libncursesw5-dev libreadline-dev libsqlite3-dev libgdbm-dev libdb5.3-dev libbz2-dev libexpat1-dev liblzma-dev tk-dev libffi-dev 45 +sudo apt install build-essential libssl-dev zlib1g-dev libncurses5-dev \
  46 + libncursesw5-dev libreadline-dev libsqlite3-dev libgdbm-dev libdb5.3-dev \
  47 + libbz2-dev libexpat1-dev liblzma-dev tk-dev libffi-dev
42 ``` 48 ```
43 49
44 -Download python from [http://www.python.org]() and 50 +Download [python](http://www.python.org) and
45 51
46 ```sh 52 ```sh
47 tar xvfJ Python-3.7.tar.xz 53 tar xvfJ Python-3.7.tar.xz
@@ -50,32 +56,27 @@ cd Python-3.7 @@ -50,32 +56,27 @@ cd Python-3.7
50 make && make install 56 make && make install
51 ``` 57 ```
52 58
53 -This will install python locally under `~/.local/bin`. Make sure to add it to your `PATH` in `~/.profile`. If `~/bin` is already in the path, you may just make a symbolic link `ln -s ~/.local/bin ~/bin`. 59 +This will install python locally under `~/.local/bin`. Make sure to add it to
  60 +your `PATH` in `~/.profile`. If `~/bin` is already in the path, just make a
  61 +symbolic link `ln -s ~/.local/bin ~/bin`.
54 62
55 ### Install pip 63 ### Install pip
56 64
57 -Python usually includes pip which is accessible through `python -m pip install something`, but it's also convenient to have the `pip` command directly available in the terminal.  
58 -To install `pip` from the system package manager: 65 +Install `pip` from the system package manager:
59 66
60 ```sh 67 ```sh
61 -sudo apt install python3.7-pip # Ubuntu 19.04+  
62 -sudo pkg py37-pip # FreeBSD  
63 -sudo port install py37-pip # MacOS 68 +sudo apt install python3-pip # Ubuntu
  69 +sudo pkg py38-pip # FreeBSD
  70 +sudo port install py39-pip # MacOS
64 ``` 71 ```
65 72
66 -otherwise run: 73 +Then run `python3 -m pip install -U pip` to install latest version into your
  74 +user account under `~/.local/bin`.
  75 +In the end you should be able to run `pip --version` and `python3 -c "import
  76 +sqlite3"` without errors.
  77 +In some systems, `pip` can be named `pip3`, `pip3.8` or `pip-3.8`.
67 78
68 -```sh  
69 -python3.7 -m pip install pip # install in user area  
70 -```  
71 -  
72 -The latter will install `pip` in your user account under `~/.local/bin`.  
73 -In the end you should be able to run `pip --version` and  
74 -`python3 -c "import sqlite3"` without errors.  
75 -Sometimes the `pip` command is named `pip3`,  
76 -`pip3.7` or `pip-3.7`.  
77 -  
78 -Edit the configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or 79 +Edit the configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or
79 `Library/Application Support/pip/pip.conf` (MacOS) and add the lines 80 `Library/Application Support/pip/pip.conf` (MacOS) and add the lines
80 81
81 ```ini 82 ```ini
@@ -85,7 +86,7 @@ user = yes @@ -85,7 +86,7 @@ user = yes
85 86
86 This will set pip to install modules in the user area (recommended). 87 This will set pip to install modules in the user area (recommended).
87 88
88 -### Download and install aprendizations: 89 +### Download and install aprendizations
89 90
90 ```sh 91 ```sh
91 git clone https://git.xdi.uevora.pt/mjsb/aprendizations.git 92 git clone https://git.xdi.uevora.pt/mjsb/aprendizations.git
@@ -94,20 +95,23 @@ npm install # install javascript libraries @@ -94,20 +95,23 @@ npm install # install javascript libraries
94 pip install . # install aprendizations and dependencies 95 pip install . # install aprendizations and dependencies
95 ``` 96 ```
96 97
97 -Javascript libraries are installed in `aprendizations/node_modules` and are linked from `aprendizations/aprendizations/static`. 98 +Javascript libraries are installed in `aprendizations/node_modules` and are
  99 +linked from `aprendizations/aprendizations/static`.
98 100
99 Python packages are usually installed in: 101 Python packages are usually installed in:
100 102
101 -- `~/.local/lib/python3.7/site-packages/` in Linux/FreeBSD.  
102 -- `~/Library/python/3.7/lib/python/site-packages/` in MacOS. 103 +* `~/.local/lib/python3.8/site-packages/` in Linux/FreeBSD.
  104 +* `~/Library/python/3.9/lib/python/site-packages/` in MacOS.
103 105
104 -When aprendizations is installed with pip, all the dependencies are also installed. The javascript libraries previously installed with npm are copied to the above directory and the cloned repository is no longer needed. 106 +When aprendizations is installed with pip, all the dependencies are also
  107 +installed. The javascript libraries previously installed with npm are copied to
  108 +the above directory and the cloned repository is no longer needed.
105 109
106 At this point `aprendizations` is installed in 110 At this point `aprendizations` is installed in
107 111
108 ```sh 112 ```sh
109 ~/.local/bin # Linux/FreeBSD 113 ~/.local/bin # Linux/FreeBSD
110 -~/Library/Python/3.7/bin # MacOS 114 +~/Library/Python/3.9/bin # MacOS
111 ``` 115 ```
112 116
113 and can be run from the terminal: 117 and can be run from the terminal:
@@ -119,10 +123,12 @@ aprendizations --help @@ -119,10 +123,12 @@ aprendizations --help
119 123
120 ### SSL Certificates 124 ### SSL Certificates
121 125
122 -We need certificates for https. Certificates can be self-signed or validated by a trusted authority. 126 +We need certificates for https. Certificates can be self-signed or validated by
  127 +a trusted authority.
123 128
124 -Self-signed can be used locally for development and testing, but browsers will  
125 -complain. LetsEncrypt issues trusted and free certificates, but the server must have a registered publicly accessible domain name. 129 +Self-signed can be used locally for development and testing, but browsers will
  130 +complain. LetsEncrypt issues trusted and free certificates, but the server must
  131 +have a registered publicly accessible domain name.
126 132
127 #### Generating selfsigned certificates 133 #### Generating selfsigned certificates
128 134
@@ -137,33 +143,36 @@ openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 - @@ -137,33 +143,36 @@ openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -
137 Install the certbot from LetsEncrypt: 143 Install the certbot from LetsEncrypt:
138 144
139 ```sh 145 ```sh
140 -sudo pkg install py36-certbot # FreeBSD 146 +sudo pkg install py38-certbot # FreeBSD
141 sudo apt install certbot # Ubuntu 147 sudo apt install certbot # Ubuntu
142 ``` 148 ```
143 149
144 -To generate or renew the certificates, ports 80 and 443 have to be accessible. The firewall and webserver have to be stopped. 150 +To generate or renew the certificates, ports 80 and 443 must be accessible.
  151 +**Any firewall and webserver have to be stopped**.
145 152
146 ```sh 153 ```sh
147 sudo certbot certonly --standalone -d www.example.com # first time 154 sudo certbot certonly --standalone -d www.example.com # first time
148 sudo certbot renew # renew 155 sudo certbot renew # renew
149 ``` 156 ```
150 157
151 -Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `~/.local/share/certs` and change permissions to be readable: 158 +Certificates are saved under
  159 +`/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to
  160 +`~/.local/share/certs` and change permissions to be readable:
152 161
153 ```sh 162 ```sh
  163 +cd ~/.local/share/certs
154 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem . 164 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem .
155 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem . 165 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem .
156 chmod 400 cert.pem privkey.pem 166 chmod 400 cert.pem privkey.pem
157 ``` 167 ```
158 168
159 -  
160 ## Configuration 169 ## Configuration
161 170
162 ### Database 171 ### Database
163 172
164 -User data is maintained in a sqlite3 database which has to be created manually using the `initdb-aprendizations` command.  
165 -The database file should be located in the same directory as the main  
166 -YAML configuration file. 173 +User data is maintained in a sqlite3 database which has to be created manually
  174 +using the `initdb-aprendizations` command. The database file should be located
  175 +in the same directory as the main YAML configuration file.
167 176
168 For example, to run the included demo do: 177 For example, to run the included demo do:
169 178
@@ -179,26 +188,27 @@ initdb-aprendizations --help # for available options @@ -179,26 +188,27 @@ initdb-aprendizations --help # for available options
179 188
180 The default password is equal to the user name, if left undefined. 189 The default password is equal to the user name, if left undefined.
181 190
182 -  
183 ### Running the demo 191 ### Running the demo
184 192
185 -The application includes a small example in `demo/demo.yaml` that can be used for initial testing. Run it with 193 +The application includes a small example in `demo/demo.yaml` that can be used
  194 +for initial testing. Run it with
186 195
187 ```sh 196 ```sh
188 cd demo 197 cd demo
189 aprendizations demo.yaml 198 aprendizations demo.yaml
190 ``` 199 ```
191 200
192 -Open the browser at [https://127.0.0.1:8443](https://127.0.0.1:8443).  
193 -If everything looks good, check at the correct address 201 +Open the browser at [https://127.0.0.1:8443](https://127.0.0.1:8443).
  202 +If everything looks good, check at the correct address
194 `https://www.example.com:8443`. 203 `https://www.example.com:8443`.
195 -The option `--debug` provides more verbose logging and might  
196 -be useful during testing. 204 +The option `--debug` provides more verbose logging and might be useful during
  205 +testing.
197 206
198 ### Firewall configuration 207 ### Firewall configuration
199 208
200 -Ports 80 and 443 are only usable by root. For security reasons the server runs as an unprivileged user on port 8443 for https.  
201 -To access the server in the default https port (443), port forwarding can be configured in the firewall. 209 +Ports 80 and 443 are only usable by root. For security reasons the server runs
  210 +as an unprivileged user on port 8443 for https. To access the server in the
  211 +default https port (443), port forwarding must be configured in the firewall.
202 212
203 #### FreeBSD and pf 213 #### FreeBSD and pf
204 214
@@ -231,14 +241,14 @@ Reboot or `sudo service pf start`. @@ -231,14 +241,14 @@ Reboot or `sudo service pf start`.
231 241
232 Make sure the following steps have been done: 242 Make sure the following steps have been done:
233 243
234 -- installed python3.7, pip and npm  
235 -- git-cloned the aprendizations from the main repository  
236 -- installed javascript libraries with npm  
237 -- installed aprendizations with pip  
238 -- initialized database with at least 1 user  
239 -- generate and copy certificates to the appropriate place  
240 -- (optional) configure the firewall to do port forwarding  
241 -- run `aprendizations demo.yaml --check` 244 +* installed python3, pip and npm
  245 +* git-cloned the aprendizations from the main repository
  246 +* installed javascript libraries with npm
  247 +* installed aprendizations with pip
  248 +* initialized database with at least 1 user
  249 +* generate and copy certificates to the appropriate place
  250 +* (optional) configure the firewall to do port forwarding
  251 +* run `aprendizations demo.yaml --check`
242 252
243 ## Keeping aprendizations updated 253 ## Keeping aprendizations updated
244 254
@@ -248,28 +258,29 @@ To update aprendizations to the latest version do: @@ -248,28 +258,29 @@ To update aprendizations to the latest version do:
248 cd aprendizations 258 cd aprendizations
249 git pull # get latest version 259 git pull # get latest version
250 npm update # update javascript libraries 260 npm update # update javascript libraries
251 -pip install -U . # updates installed version to latest 261 +pip install -U . # updates installed version
252 ``` 262 ```
253 263
254 ## Troubleshooting 264 ## Troubleshooting
255 265
256 -To help with troubleshooting, use the option `--debug` when running the server.  
257 -This will increase logs in the terminal and will present the python exception 266 +To help with troubleshooting, use the option `--debug` when running the server.
  267 +This will increase logs in the terminal and will present the python exception
258 errors in the browser. 268 errors in the browser.
259 269
260 -Logging levels can be adjusted in `~/.config/aprendizations/logger.yaml` and 270 +Logging levels can be adjusted in `~/.config/aprendizations/logger.yaml` and
261 `~/.config/aprendizations/logger-debug.yaml`. 271 `~/.config/aprendizations/logger-debug.yaml`.
262 272
263 -If these files do not yet exist, there are examples in `aprendizations/config` that can be copied to `~/.config/aprendizations`. 273 +If these files do not yet exist, there are examples in `aprendizations/config`
  274 +that can be copied to `~/.config/aprendizations`.
264 275
265 -#### UnicodeEncodeError 276 +### UnicodeEncodeError
266 277
267 -The server should not generate this error, but when using external scripts to  
268 -generate questions or to correct, these scripts can print unicode strings to  
269 -stdout. If the terminal does not support unicode, python will generate this 278 +The server should not generate this error, but when using external scripts to
  279 +generate questions or to correct, these scripts can print unicode strings to
  280 +stdout. If the terminal does not support unicode, python will generate an
270 exception. 281 exception.
271 282
272 -- FreeBSD fix: edit `~/.login_conf` to use UTF-8, for example: 283 +* FreeBSD fix: edit `~/.login_conf` to use UTF-8, for example:
273 284
274 ```sh 285 ```sh
275 me:\ 286 me:\
@@ -277,7 +288,21 @@ me:\ @@ -277,7 +288,21 @@ me:\
277 :lang=en_US.UTF-8: 288 :lang=en_US.UTF-8:
278 ``` 289 ```
279 290
280 -- Debian fix: check `locale`... 291 +* Debian fix: check `locale`...
  292 +
  293 +### The application runs but questions do not show up
  294 +
  295 +Some operating systems have an option to disable animations to try to avoid
  296 +motion sickness in some people. Browsers will check this option with the OS and
  297 +prevent animate.css library to work. Since questions have several animations,
  298 +these will will not work and nothing is shown on the page.
  299 +
  300 +To fix this issue you need to allow animations in the Operating System:
  301 +
  302 +* On windows 10, go to System Preferences, search for "Show animations in
  303 + windows" and turn it **ON**.
  304 +* On MacOS or iOS search for reduced motion and switch it **OFF**
  305 + (Preferences -> Acessibility -> Display -> Reduce motion).
281 306
282 ## FAQ 307 ## FAQ
283 308
@@ -295,9 +320,10 @@ Some common database queries: @@ -295,9 +320,10 @@ Some common database queries:
295 sqlite3 students.db "select distinct student_id from studenttopic" 320 sqlite3 students.db "select distinct student_id from studenttopic"
296 321
297 # How many topics has each student done? 322 # How many topics has each student done?
298 -sqlite3 students.db "select student_id, count(topic_id) from studenttopic group by student_id order by count(topic_id) desc" 323 +sqlite3 students.db "select student_id, count(topic_id) from studenttopic \
  324 + group by student_id order by count(topic_id) desc"
299 325
300 # Which questions have more wrong answers? 326 # Which questions have more wrong answers?
301 -sqlite3 students.db "select count(ref), ref from answers where grade<1.0 group by ref order by count(ref) desc" 327 +sqlite3 students.db "select count(ref), ref from answers where grade<1.0 \
  328 + group by ref order by count(ref) desc"
302 ``` 329 ```
303 -  
aprendizations/__init__.py
@@ -30,10 +30,10 @@ are progressively uncovered as the students progress. @@ -30,10 +30,10 @@ are progressively uncovered as the students progress.
30 ''' 30 '''
31 31
32 APP_NAME = 'aprendizations' 32 APP_NAME = 'aprendizations'
33 -APP_VERSION = '2020.01.dev4' 33 +APP_VERSION = '2021.08.dev1'
34 APP_DESCRIPTION = __doc__ 34 APP_DESCRIPTION = __doc__
35 35
36 __author__ = 'Miguel Barão' 36 __author__ = 'Miguel Barão'
37 -__copyright__ = 'Copyright 2020, Miguel Barão' 37 +__copyright__ = 'Copyright 2021, Miguel Barão'
38 __license__ = 'MIT license' 38 __license__ = 'MIT license'
39 __version__ = APP_VERSION 39 __version__ = APP_VERSION
aprendizations/initdb.py
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
  3 +'''
  4 +Initializes or updates database
  5 +'''
  6 +
3 # python standard libraries 7 # python standard libraries
4 import csv 8 import csv
5 import argparse 9 import argparse
6 import re 10 import re
7 from string import capwords 11 from string import capwords
8 -from concurrent.futures import ThreadPoolExecutor  
9 12
10 # third party libraries 13 # third party libraries
11 import bcrypt 14 import bcrypt
12 -import sqlalchemy as sa 15 +from sqlalchemy import create_engine, select
  16 +from sqlalchemy.orm import Session
  17 +from sqlalchemy.exc import IntegrityError, NoResultFound
13 18
14 # this project 19 # this project
15 -from .models import Base, Student 20 +from aprendizations.models import Base, Student
16 21
17 22
18 # =========================================================================== 23 # ===========================================================================
19 # Parse command line options 24 # Parse command line options
20 def parse_commandline_arguments(): 25 def parse_commandline_arguments():
  26 + '''Parse command line arguments'''
  27 +
21 argparser = argparse.ArgumentParser( 28 argparser = argparse.ArgumentParser(
22 formatter_class=argparse.ArgumentDefaultsHelpFormatter, 29 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
23 description='Insert new users into a database. Users can be imported ' 30 description='Insert new users into a database. Users can be imported '
@@ -65,9 +72,12 @@ def parse_commandline_arguments(): @@ -65,9 +72,12 @@ def parse_commandline_arguments():
65 72
66 73
67 # =========================================================================== 74 # ===========================================================================
68 -# SIIUE names have alien strings like "(TE)" and are sometimes capitalized  
69 -# We remove them so that students dont keep asking what it means  
70 def get_students_from_csv(filename): 75 def get_students_from_csv(filename):
  76 + '''Reads CSV file with enrolled students in SIIUE format.
  77 + SIIUE names can have suffixes like "(TE)" and are sometimes capitalized.
  78 + These suffixes are removed.'''
  79 +
  80 + # SIIUE format for CSV files
71 csv_settings = { 81 csv_settings = {
72 'delimiter': ';', 82 'delimiter': ';',
73 'quotechar': '"', 83 'quotechar': '"',
@@ -75,8 +85,8 @@ def get_students_from_csv(filename): @@ -75,8 +85,8 @@ def get_students_from_csv(filename):
75 } 85 }
76 86
77 try: 87 try:
78 - with open(filename, encoding='iso-8859-1') as f:  
79 - csvreader = csv.DictReader(f, **csv_settings) 88 + with open(filename, encoding='iso-8859-1') as file:
  89 + csvreader = csv.DictReader(file, **csv_settings)
80 students = [{ 90 students = [{
81 'uid': s['N.º'], 91 'uid': s['N.º'],
82 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) 92 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
@@ -92,105 +102,93 @@ def get_students_from_csv(filename): @@ -92,105 +102,93 @@ def get_students_from_csv(filename):
92 102
93 103
94 # =========================================================================== 104 # ===========================================================================
95 -# replace password by hash for a single student  
96 -def hashpw(student, pw=None):  
97 - print('.', end='', flush=True)  
98 - pw = (pw or student.get('pw', None) or student['uid']).encode('utf-8')  
99 - student['pw'] = bcrypt.hashpw(pw, bcrypt.gensalt())  
100 -  
101 -  
102 -# ===========================================================================  
103 def show_students_in_database(session, verbose=False): 105 def show_students_in_database(session, verbose=False):
104 - try:  
105 - users = session.query(Student).all()  
106 - except Exception:  
107 - raise  
108 - else:  
109 - n = len(users)  
110 - print(f'\nRegistered users:')  
111 - if n == 0:  
112 - print(' -- none --') 106 + '''shows students in the database'''
  107 + users = session.execute(select(Student)).scalars().all()
  108 + total = len(users)
  109 +
  110 + print('\nRegistered users:')
  111 + if users:
  112 + users.sort(key=lambda u: f'{u.id:>12}') # sort by right aligned string
  113 + if verbose:
  114 + for user in users:
  115 + print(f'{user.id:>12} {user.name}')
113 else: 116 else:
114 - users.sort(key=lambda u: f'{u.id:>12}') # sort by number  
115 - if verbose:  
116 - for u in users:  
117 - print(f'{u.id:>12} {u.name}')  
118 - else:  
119 - print(f'{users[0].id:>12} {users[0].name}')  
120 - if n > 1:  
121 - print(f'{users[1].id:>12} {users[1].name}')  
122 - if n > 3:  
123 - print(' | |')  
124 - if n > 2:  
125 - print(f'{users[-1].id:>12} {users[-1].name}')  
126 - print(f'Total: {n}.') 117 + print(f'{users[0].id:>12} {users[0].name}')
  118 + if total > 1:
  119 + print(f'{users[1].id:>12} {users[1].name}')
  120 + if total > 3:
  121 + print(' | |')
  122 + if total > 2:
  123 + print(f'{users[-1].id:>12} {users[-1].name}')
  124 + print(f'Total: {total}.')
127 125
128 126
129 # =========================================================================== 127 # ===========================================================================
130 def main(): 128 def main():
  129 + '''performs the main functions'''
  130 +
131 args = parse_commandline_arguments() 131 args = parse_commandline_arguments()
132 132
133 # --- database stuff 133 # --- database stuff
134 - print(f'Using database: ', args.db)  
135 - engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) 134 + print(f'Database: {args.db}')
  135 + engine = create_engine(f'sqlite:///{args.db}', echo=False, future=True)
136 Base.metadata.create_all(engine) # Creates schema if needed 136 Base.metadata.create_all(engine) # Creates schema if needed
137 - Session = sa.orm.sessionmaker(bind=engine)  
138 - session = Session() 137 + session = Session(engine, future=True)
139 138
140 - # --- make list of students to insert/update 139 + # --- build list of students to insert/update
141 students = [] 140 students = []
142 141
143 for csvfile in args.csvfile: 142 for csvfile in args.csvfile:
144 - # print('Adding users from:', csvfile)  
145 - students.extend(get_students_from_csv(csvfile)) 143 + students += get_students_from_csv(csvfile)
146 144
147 if args.admin: 145 if args.admin:
148 - # print('Adding user: 0, Admin.')  
149 students.append({'uid': '0', 'name': 'Admin'}) 146 students.append({'uid': '0', 'name': 'Admin'})
150 147
151 if args.add: 148 if args.add:
152 for uid, name in args.add: 149 for uid, name in args.add:
153 - # print(f'Adding user: {uid}, {name}.')  
154 students.append({'uid': uid, 'name': name}) 150 students.append({'uid': uid, 'name': name})
155 151
156 # --- only insert students that are not yet in the database 152 # --- only insert students that are not yet in the database
157 - db_students = {user.id for user in session.query(Student).all()}  
158 - new_students = list(filter(lambda s: s['uid'] not in db_students, students))  
159 -  
160 - if new_students:  
161 - # --- password hashing  
162 - print(f'Generating password hashes', end='')  
163 - with ThreadPoolExecutor() as executor:  
164 - executor.map(lambda s: hashpw(s, args.pw), new_students)  
165 -  
166 - print('\nAdding students:')  
167 - for s in new_students:  
168 - print(f' + {s["uid"]}, {s["name"]}')  
169 -  
170 - try:  
171 - session.add_all([Student(id=s['uid'],  
172 - name=s['name'],  
173 - password=s['pw'])  
174 - for s in new_students])  
175 - session.commit()  
176 - except sa.exc.IntegrityError:  
177 - print('!!! Integrity error. Aborted !!!\n')  
178 - session.rollback()  
179 -  
180 - print(f'Inserted {len(new_students)} new student(s).') 153 + print('\nInserting new students:')
  154 +
  155 + db_students = set(session.execute(select(Student.id)).scalars().all())
  156 + new_students = (s for s in students if s['uid'] not in db_students)
  157 + count = 0
  158 + for s in new_students:
  159 + print(f' {s["uid"]}, {s["name"]}')
  160 +
  161 + pw = args.pw or s['uid']
  162 + hashed_pw = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt())
  163 +
  164 + session.add(Student(id=s['uid'], name=s['name'], password=hashed_pw))
  165 + count += 1
  166 +
  167 + try:
  168 + session.commit()
  169 + except IntegrityError:
  170 + print('!!! Integrity error. Aborted !!!\n')
  171 + session.rollback()
181 else: 172 else:
182 - print('There are no new students to add.') 173 + print(f'Total {count} new student(s).')
183 174
184 # --- update data for student in the database 175 # --- update data for student in the database
185 - for s in args.update:  
186 - print(f'Updating password of: {s}')  
187 - u = session.query(Student).get(s)  
188 - if u is not None:  
189 - pw = (args.pw or s).encode('utf-8')  
190 - u.password = bcrypt.hashpw(pw, bcrypt.gensalt())  
191 - session.commit()  
192 - else:  
193 - print(f'!!! Student {s} does not exist. Skipping update !!!') 176 + if args.update:
  177 + print('\nUpdating passwords of students:')
  178 + count = 0
  179 + for sid in args.update:
  180 + try:
  181 + s = session.execute(select(Student).filter_by(id=sid)).scalar_one()
  182 + except NoResultFound:
  183 + print(f' -> student {sid} does not exist!')
  184 + continue
  185 + else:
  186 + print(f' {sid}, {s.name}')
  187 + count += 1
  188 + pw = args.pw or sid
  189 + s.password = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt())
  190 + session.commit()
  191 + print(f'Total {count} password(s) updated.')
194 192
195 show_students_in_database(session, args.verbose) 193 show_students_in_database(session, args.verbose)
196 194
aprendizations/learnapp.py
  1 +'''
  2 +Learn application.
  3 +This is the main controller of the application.
  4 +'''
1 5
2 # python standard library 6 # python standard library
3 import asyncio 7 import asyncio
4 from collections import defaultdict 8 from collections import defaultdict
5 -from contextlib import contextmanager # `with` statement in db sessions 9 +# from contextlib import contextmanager # `with` statement in db sessions
6 from datetime import datetime 10 from datetime import datetime
7 import logging 11 import logging
8 from random import random 12 from random import random
9 -from os import path 13 +from os.path import join, exists
10 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict 14 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
11 15
12 # third party libraries 16 # third party libraries
13 import bcrypt 17 import bcrypt
14 import networkx as nx 18 import networkx as nx
15 -import sqlalchemy as sa 19 +from sqlalchemy import create_engine, select, func
  20 +from sqlalchemy.orm import Session
  21 +from sqlalchemy.exc import NoResultFound
16 22
17 # this project 23 # this project
18 -from .models import Student, Answer, Topic, StudentTopic  
19 -from .questions import Question, QFactory, QDict, QuestionException  
20 -from .student import StudentState  
21 -from .tools import load_yaml 24 +from aprendizations.models import Student, Answer, Topic, StudentTopic
  25 +from aprendizations.questions import Question, QFactory, QDict, QuestionException
  26 +from aprendizations.student import StudentState
  27 +from aprendizations.tools import load_yaml
22 28
23 29
24 # setup logger for this module 30 # setup logger for this module
@@ -27,185 +33,174 @@ logger = logging.getLogger(__name__) @@ -27,185 +33,174 @@ logger = logging.getLogger(__name__)
27 33
28 # ============================================================================ 34 # ============================================================================
29 class LearnException(Exception): 35 class LearnException(Exception):
30 - pass 36 + '''Exceptions raised from the LearnApp class'''
31 37
32 38
33 class DatabaseUnusableError(LearnException): 39 class DatabaseUnusableError(LearnException):
34 - pass 40 + '''Exception raised if the database fails in the initialization'''
35 41
36 42
37 # ============================================================================ 43 # ============================================================================
38 -# LearnApp - application logic  
39 -#  
40 -# self.deps - networkx topic dependencies  
41 -# self.courses - dict {course_id: {'title': ...,  
42 -# 'description': ...,  
43 -# 'goals': ...,}, ...}  
44 -# self.factory = dict {qref: QFactory()}  
45 -# self.online - dict {student_id: {'number': ...,  
46 -# 'name': ...,  
47 -# 'state': StudentState(),  
48 -# 'counter': ...}, ...}  
49 -# ============================================================================  
50 -class LearnApp(object):  
51 - # ------------------------------------------------------------------------  
52 - # helper to manage db sessions using the `with` statement, for example  
53 - # with self.db_session() as s: s.query(...)  
54 - # ------------------------------------------------------------------------  
55 - @contextmanager  
56 - def db_session(self, **kw):  
57 - session = self.Session(**kw)  
58 - try:  
59 - yield session  
60 - session.commit()  
61 - except Exception:  
62 - logger.error('!!! Database rollback !!!')  
63 - session.rollback()  
64 - raise  
65 - finally:  
66 - session.close() 44 +class LearnApp():
  45 + '''
  46 + LearnApp - application logic
  47 +
  48 + self.deps - networkx topic dependencies
  49 + self.courses - dict {course_id: {'title': ...,
  50 + 'description': ...,
  51 + 'goals': ...,}, ...}
  52 + self.factory = dict {qref: QFactory()}
  53 + self.online - dict {student_id: {'number': ...,
  54 + 'name': ...,
  55 + 'state': StudentState(),
  56 + 'counter': ...}, ...}
  57 + '''
67 58
68 # ------------------------------------------------------------------------ 59 # ------------------------------------------------------------------------
69 - # init  
70 - # ------------------------------------------------------------------------  
71 def __init__(self, 60 def __init__(self,
72 courses: str, # filename with course configurations 61 courses: str, # filename with course configurations
73 prefix: str, # path to topics 62 prefix: str, # path to topics
74 db: str, # database filename 63 db: str, # database filename
75 check: bool = False) -> None: 64 check: bool = False) -> None:
76 65
77 - self.db_setup(db) # setup database and check students 66 + self._db_setup(db) # setup database and check students
78 self.online: Dict[str, Dict] = dict() # online students 67 self.online: Dict[str, Dict] = dict() # online students
79 68
80 try: 69 try:
81 config: Dict[str, Any] = load_yaml(courses) 70 config: Dict[str, Any] = load_yaml(courses)
82 - except Exception: 71 + except Exception as exc:
83 msg = f'Failed to load yaml file "{courses}"' 72 msg = f'Failed to load yaml file "{courses}"'
84 logger.error(msg) 73 logger.error(msg)
85 - raise LearnException(msg) 74 + raise LearnException(msg) from exc
86 75
87 # --- topic dependencies are shared between all courses 76 # --- topic dependencies are shared between all courses
88 self.deps = nx.DiGraph(prefix=prefix) 77 self.deps = nx.DiGraph(prefix=prefix)
89 logger.info('Populating topic graph:') 78 logger.info('Populating topic graph:')
90 79
91 - t = config.get('topics', {}) # topics defined directly in courses file  
92 - self.populate_graph(t)  
93 - logger.info(f'{len(t):>6} topics in {courses}')  
94 - for f in config.get('topics_from', []):  
95 - c = load_yaml(f) # course configuration 80 + # topics defined directly in the courses file, usually empty
  81 + base_topics = config.get('topics', {})
  82 + self._populate_graph(base_topics)
  83 + logger.info('%6d topics in %s', len(base_topics), courses)
  84 +
  85 + # load other course files with the topics the their deps
  86 + for course_file in config.get('topics_from', []):
  87 + course_conf = load_yaml(course_file) # course configuration
96 # FIXME set defaults?? 88 # FIXME set defaults??
97 - logger.info(f'{len(c["topics"]):>6} topics imported from {f}')  
98 - self.populate_graph(c)  
99 - logger.info(f'Graph has {len(self.deps)} topics') 89 + logger.info('%6d topics imported from %s',
  90 + len(course_conf["topics"]), course_file)
  91 + self._populate_graph(course_conf)
  92 + logger.info('Graph has %d topics', len(self.deps))
100 93
101 # --- courses dict 94 # --- courses dict
102 self.courses = config['courses'] 95 self.courses = config['courses']
103 - logger.info(f'Courses: {", ".join(self.courses.keys())}')  
104 - for c, d in self.courses.items():  
105 - d.setdefault('title', '') # course title undefined  
106 - for goal in d['goals']: 96 + logger.info('Courses: %s', ', '.join(self.courses.keys()))
  97 + for cid, course in self.courses.items():
  98 + course.setdefault('title', cid) # course title undefined
  99 + for goal in course['goals']:
107 if goal not in self.deps.nodes(): 100 if goal not in self.deps.nodes():
108 - msg = f'Goal "{goal}" from course "{c}" does not exist' 101 + msg = f'Goal "{goal}" from course "{cid}" does not exist'
109 logger.error(msg) 102 logger.error(msg)
110 raise LearnException(msg) 103 raise LearnException(msg)
111 - elif self.deps.nodes[goal]['type'] == 'chapter':  
112 - d['goals'] += [g for g in self.deps.predecessors(goal)  
113 - if g not in d['goals']] 104 + if self.deps.nodes[goal]['type'] == 'chapter':
  105 + course['goals'] += [g for g in self.deps.predecessors(goal)
  106 + if g not in course['goals']]
114 107
115 # --- factory is a dict with question generators for all topics 108 # --- factory is a dict with question generators for all topics
116 - self.factory: Dict[str, QFactory] = self.make_factory() 109 + self.factory: Dict[str, QFactory] = self._make_factory()
117 110
118 # if graph has topics that are not in the database, add them 111 # if graph has topics that are not in the database, add them
119 - self.add_missing_topics(self.deps.nodes()) 112 + self._add_missing_topics(self.deps.nodes())
120 113
121 if check: 114 if check:
122 - self.sanity_check_questions() 115 + self._sanity_check_questions()
123 116
124 # ------------------------------------------------------------------------ 117 # ------------------------------------------------------------------------
125 - def sanity_check_questions(self) -> None: 118 + def _sanity_check_questions(self) -> None:
  119 + '''
  120 + Unit tests for all questions
  121 +
  122 + Generates all questions, give right and wrong answers and corrects.
  123 + '''
126 logger.info('Starting sanity checks (may take a while...)') 124 logger.info('Starting sanity checks (may take a while...)')
127 125
128 errors: int = 0 126 errors: int = 0
129 for qref in self.factory: 127 for qref in self.factory:
130 - logger.debug(f'checking {qref}...') 128 + logger.debug('checking %s...', qref)
131 try: 129 try:
132 - q = self.factory[qref].generate()  
133 - except QuestionException as e:  
134 - logger.error(e) 130 + question = self.factory[qref].generate()
  131 + except QuestionException as exc:
  132 + logger.error(exc)
135 errors += 1 133 errors += 1
136 continue # to next question 134 continue # to next question
137 135
138 - if 'tests_right' in q:  
139 - for t in q['tests_right']:  
140 - q['answer'] = t  
141 - q.correct()  
142 - if q['grade'] < 1.0:  
143 - logger.error(f'Failed right answer in "{qref}".') 136 + if 'tests_right' in question:
  137 + for right_answer in question['tests_right']:
  138 + question['answer'] = right_answer
  139 + question.correct()
  140 + if question['grade'] < 1.0:
  141 + logger.error('Failed right answer in "%s".', qref)
144 errors += 1 142 errors += 1
145 continue # to next test 143 continue # to next test
146 -  
147 - if 'tests_wrong' in q:  
148 - for t in q['tests_wrong']:  
149 - q['answer'] = t  
150 - q.correct()  
151 - if q['grade'] >= 1.0:  
152 - logger.error(f'Failed wrong answer in "{qref}".') 144 + elif question['type'] == 'textarea':
  145 + msg = f'- consider adding tests to {question["ref"]}'
  146 + logger.warning(msg)
  147 +
  148 + if 'tests_wrong' in question:
  149 + for wrong_answer in question['tests_wrong']:
  150 + question['answer'] = wrong_answer
  151 + question.correct()
  152 + if question['grade'] >= 1.0:
  153 + logger.error('Failed wrong answer in "%s".', qref)
153 errors += 1 154 errors += 1
154 continue # to next test 155 continue # to next test
155 156
156 if errors > 0: 157 if errors > 0:
157 - logger.error(f'{errors:>6} error(s) found.') 158 + logger.error('%6d error(s) found.', errors)
158 raise LearnException('Sanity checks') 159 raise LearnException('Sanity checks')
159 - else:  
160 - logger.info(' 0 errors found.') 160 + logger.info(' 0 errors found.')
161 161
162 # ------------------------------------------------------------------------ 162 # ------------------------------------------------------------------------
163 - # login  
164 - # ------------------------------------------------------------------------  
165 - async def login(self, uid: str, pw: str) -> bool:  
166 -  
167 - with self.db_session() as s:  
168 - found = s.query(Student.name, Student.password) \  
169 - .filter_by(id=uid) \  
170 - .one_or_none() 163 + async def login(self, uid: str, password: str) -> bool:
  164 + '''user login'''
171 165
172 # wait random time to minimize timing attacks 166 # wait random time to minimize timing attacks
173 await asyncio.sleep(random()) 167 await asyncio.sleep(random())
174 168
175 - loop = asyncio.get_running_loop()  
176 - if found is None:  
177 - logger.info(f'User "{uid}" does not exist')  
178 - await loop.run_in_executor(None, bcrypt.hashpw, b'',  
179 - bcrypt.gensalt()) # just spend time 169 + query = select(Student).where(Student.id == uid)
  170 + try:
  171 + with Session(self._engine, future=True) as session:
  172 + student = session.execute(query).scalar_one()
  173 + except NoResultFound:
  174 + logger.info('User "%s" does not exist', uid)
180 return False 175 return False
181 176
182 - else:  
183 - name, hashed_pw = found  
184 - pw_ok: bool = await loop.run_in_executor(None,  
185 - bcrypt.checkpw,  
186 - pw.encode('utf-8'),  
187 - hashed_pw) 177 + loop = asyncio.get_running_loop()
  178 + pw_ok: bool = await loop.run_in_executor(None,
  179 + bcrypt.checkpw,
  180 + password.encode('utf-8'),
  181 + student.password)
188 182
189 if pw_ok: 183 if pw_ok:
190 if uid in self.online: 184 if uid in self.online:
191 - logger.warning(f'User "{uid}" already logged in') 185 + logger.warning('User "%s" already logged in', uid)
192 counter = self.online[uid]['counter'] 186 counter = self.online[uid]['counter']
193 else: 187 else:
194 - logger.info(f'User "{uid}" logged in') 188 + logger.info('User "%s" logged in', uid)
195 counter = 0 189 counter = 0
196 190
197 - # get topics of this student and set its current state  
198 - with self.db_session() as s:  
199 - tt = s.query(StudentTopic).filter_by(student_id=uid) 191 + # get topics for this student and set its current state
  192 + query = select(StudentTopic).where(StudentTopic.student_id == uid)
  193 + with Session(self._engine, future=True) as session:
  194 + student_topics = session.execute(query).scalars().all()
200 195
201 state = {t.topic_id: { 196 state = {t.topic_id: {
202 'level': t.level, 197 'level': t.level,
203 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f") 198 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f")
204 - } for t in tt} 199 + } for t in student_topics}
205 200
206 self.online[uid] = { 201 self.online[uid] = {
207 'number': uid, 202 'number': uid,
208 - 'name': name, 203 + 'name': student.name,
209 'state': StudentState(uid=uid, state=state, 204 'state': StudentState(uid=uid, state=state,
210 courses=self.courses, deps=self.deps, 205 courses=self.courses, deps=self.deps,
211 factory=self.factory), 206 factory=self.factory),
@@ -213,178 +208,208 @@ class LearnApp(object): @@ -213,178 +208,208 @@ class LearnApp(object):
213 } 208 }
214 209
215 else: 210 else:
216 - logger.info(f'User "{uid}" wrong password') 211 + logger.info('User "%s" wrong password', uid)
217 212
218 return pw_ok 213 return pw_ok
219 214
220 # ------------------------------------------------------------------------ 215 # ------------------------------------------------------------------------
221 - # logout  
222 - # ------------------------------------------------------------------------  
223 def logout(self, uid: str) -> None: 216 def logout(self, uid: str) -> None:
  217 + '''User logout'''
224 del self.online[uid] 218 del self.online[uid]
225 - logger.info(f'User "{uid}" logged out') 219 + logger.info('User "%s" logged out', uid)
226 220
227 # ------------------------------------------------------------------------ 221 # ------------------------------------------------------------------------
228 - # change_password. returns True if password is successfully changed.  
229 - # ------------------------------------------------------------------------  
230 - async def change_password(self, uid: str, pw: str) -> bool:  
231 - if not pw: 222 + async def change_password(self, uid: str, password: str) -> bool:
  223 + '''
  224 + Change user Password.
  225 + Returns True if password is successfully changed
  226 + '''
  227 + if not password:
232 return False 228 return False
233 229
234 loop = asyncio.get_running_loop() 230 loop = asyncio.get_running_loop()
235 - pw = await loop.run_in_executor(None, bcrypt.hashpw,  
236 - pw.encode('utf-8'), bcrypt.gensalt())  
237 -  
238 - with self.db_session() as s:  
239 - u = s.query(Student).get(uid)  
240 - u.password = pw 231 + hashed_pw = await loop.run_in_executor(None,
  232 + bcrypt.hashpw,
  233 + password.encode('utf-8'),
  234 + bcrypt.gensalt())
  235 +
  236 + with Session(self._engine, future=True) as session:
  237 + query = select(Student).where(Student.id == uid)
  238 + user = session.execute(query).scalar_one()
  239 + user.password = hashed_pw
  240 + session.commit()
241 241
242 - logger.info(f'User "{uid}" changed password') 242 + logger.info('User "%s" changed password', uid)
243 return True 243 return True
244 244
245 # ------------------------------------------------------------------------ 245 # ------------------------------------------------------------------------
246 - # Checks answer and update database. Returns corrected question.  
247 - # ------------------------------------------------------------------------  
248 async def check_answer(self, uid: str, answer) -> Question: 246 async def check_answer(self, uid: str, answer) -> Question:
  247 + '''
  248 + Checks answer and update database.
  249 + Returns corrected question.
  250 + '''
249 student = self.online[uid]['state'] 251 student = self.online[uid]['state']
250 await student.check_answer(answer) 252 await student.check_answer(answer)
251 - q: Question = student.get_current_question()  
252 253
253 - logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') 254 + topic_id = student.get_current_topic()
  255 + question: Question = student.get_current_question()
  256 + grade = question["grade"]
  257 + ref = question["ref"]
  258 +
  259 + logger.info('User "%s" got %.2f in "%s"', uid, grade, ref)
254 260
255 # always save grade of answered question 261 # always save grade of answered question
256 - with self.db_session() as s:  
257 - s.add(Answer(  
258 - ref=q['ref'],  
259 - grade=q['grade'],  
260 - starttime=str(q['start_time']),  
261 - finishtime=str(q['finish_time']),  
262 - student_id=uid,  
263 - topic_id=student.get_current_topic())) 262 + answer = Answer(ref=ref,
  263 + grade=grade,
  264 + starttime=str(question['start_time']),
  265 + finishtime=str(question['finish_time']),
  266 + student_id=uid,
  267 + topic_id=topic_id)
  268 + with Session(self._engine, future=True) as session:
  269 + session.add(answer)
  270 + session.commit()
264 271
265 - return q 272 + return question
266 273
267 # ------------------------------------------------------------------------ 274 # ------------------------------------------------------------------------
268 - # get the question to show (current or new one)  
269 - # if no more questions, save/update level in database  
270 - # ------------------------------------------------------------------------  
271 async def get_question(self, uid: str) -> Optional[Question]: 275 async def get_question(self, uid: str) -> Optional[Question]:
272 - student = self.online[uid]['state']  
273 - q: Optional[Question] = await student.get_question() 276 + '''
  277 + Get the question to show (current or new one)
  278 + If no more questions, save/update level in database
  279 + '''
  280 + student_state = self.online[uid]['state']
  281 + question: Optional[Question] = await student_state.get_question()
274 282
275 # save topic to database if finished 283 # save topic to database if finished
276 - if student.topic_has_finished():  
277 - topic: str = student.get_previous_topic()  
278 - level: float = student.get_topic_level(topic)  
279 - date: str = str(student.get_topic_date(topic))  
280 - logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})')  
281 -  
282 - with self.db_session() as s:  
283 - a = s.query(StudentTopic) \  
284 - .filter_by(student_id=uid, topic_id=topic) \  
285 - .one_or_none()  
286 - if a is None: 284 + if student_state.topic_has_finished():
  285 + topic_id: str = student_state.get_previous_topic()
  286 + level: float = student_state.get_topic_level(topic_id)
  287 + date: str = str(student_state.get_topic_date(topic_id))
  288 + logger.info('User "%s" finished "%s" (level=%.2f)',
  289 + uid, topic_id, level)
  290 +
  291 + query = select(StudentTopic).where(StudentTopic.student_id == uid).where(StudentTopic.topic_id == topic_id)
  292 + with Session(self._engine, future=True) as session:
  293 + student_topic = session.execute(query).scalar_one_or_none()
  294 +
  295 + if student_topic is None:
287 # insert new studenttopic into database 296 # insert new studenttopic into database
288 logger.debug('db insert studenttopic') 297 logger.debug('db insert studenttopic')
289 - t = s.query(Topic).get(topic)  
290 - u = s.query(Student).get(uid) 298 + query_topic = select(Topic).where(Topic.id == topic_id)
  299 + query_student = select(Student).where(Student.id == uid)
  300 + topic = session.execute(query_topic).scalar_one()
  301 + student = session.execute(query_student).scalar_one()
291 # association object 302 # association object
292 - a = StudentTopic(level=level, date=date, topic=t,  
293 - student=u)  
294 - u.topics.append(a) 303 + student_topic = StudentTopic(level=level,
  304 + date=date,
  305 + topic=topic,
  306 + student=student)
  307 + student.topics.append(student_topic)
295 else: 308 else:
296 # update studenttopic in database 309 # update studenttopic in database
297 - logger.debug(f'db update studenttopic to level {level}')  
298 - a.level = level  
299 - a.date = date 310 + logger.debug('db update studenttopic to level %f', level)
  311 + student_topic.level = level
  312 + student_topic.date = date
300 313
301 - s.add(a) 314 + session.add(student_topic)
  315 + session.commit()
302 316
303 - return q 317 + return question
304 318
305 # ------------------------------------------------------------------------ 319 # ------------------------------------------------------------------------
306 - # Start course  
307 - # ------------------------------------------------------------------------  
308 def start_course(self, uid: str, course_id: str) -> None: 320 def start_course(self, uid: str, course_id: str) -> None:
309 - student = self.online[uid]['state'] 321 + '''Start course'''
  322 +
  323 + student_state = self.online[uid]['state']
310 try: 324 try:
311 - student.start_course(course_id)  
312 - except Exception:  
313 - logger.warning(f'"{uid}" could not start course "{course_id}"')  
314 - raise 325 + student_state.start_course(course_id)
  326 + except Exception as exc:
  327 + logger.warning('"%s" could not start course "%s"', uid, course_id)
  328 + raise LearnException() from exc
315 else: 329 else:
316 - logger.info(f'User "{uid}" started course "{course_id}"') 330 + logger.info('User "%s" started course "%s"', uid, course_id)
317 331
318 # ------------------------------------------------------------------------ 332 # ------------------------------------------------------------------------
319 - # Start new topic 333 + #
320 # ------------------------------------------------------------------------ 334 # ------------------------------------------------------------------------
321 async def start_topic(self, uid: str, topic: str) -> None: 335 async def start_topic(self, uid: str, topic: str) -> None:
  336 + '''Start new topic'''
  337 +
322 student = self.online[uid]['state'] 338 student = self.online[uid]['state']
323 - if uid == '0':  
324 - logger.warning(f'Reloading "{topic}"') # FIXME should be an option  
325 - self.factory.update(self.factory_for(topic)) 339 + # if uid == '0':
  340 + # logger.warning('Reloading "%s"', topic) # FIXME should be an option
  341 + # self.factory.update(self._factory_for(topic))
326 342
327 try: 343 try:
328 await student.start_topic(topic) 344 await student.start_topic(topic)
329 - except Exception as e:  
330 - logger.warning(f'User "{uid}" could not start "{topic}": {e}') 345 + except Exception as exc:
  346 + logger.warning('User "%s" could not start "%s": %s',
  347 + uid, topic, str(exc))
331 else: 348 else:
332 - logger.info(f'User "{uid}" started topic "{topic}"')  
333 -  
334 - # ------------------------------------------------------------------------  
335 - # Fill db table 'Topic' with topics from the graph if not already there.  
336 - # ------------------------------------------------------------------------  
337 - def add_missing_topics(self, topics: List[str]) -> None:  
338 - with self.db_session() as s:  
339 - new_topics = [Topic(id=t) for t in topics  
340 - if (t,) not in s.query(Topic.id)]  
341 -  
342 - if new_topics:  
343 - s.add_all(new_topics)  
344 - logger.info(f'Added {len(new_topics)} new topic(s) to the '  
345 - f'database') 349 + logger.info('User "%s" started topic "%s"', uid, topic)
346 350
347 # ------------------------------------------------------------------------ 351 # ------------------------------------------------------------------------
348 - # setup and check database contents 352 + #
349 # ------------------------------------------------------------------------ 353 # ------------------------------------------------------------------------
350 - def db_setup(self, db: str) -> None:  
351 -  
352 - logger.info(f'Checking database "{db}":')  
353 - if not path.exists(db): 354 + def _add_missing_topics(self, topics: Iterable[str]) -> None:
  355 + '''
  356 + Fill db table 'Topic' with topics from the graph, if new
  357 + '''
  358 + with Session(self._engine, future=True) as session:
  359 + db_topics = session.execute(select(Topic.id)).scalars().all()
  360 + new = [Topic(id=t) for t in topics if t not in db_topics]
  361 + if new:
  362 + session.add_all(new)
  363 + session.commit()
  364 + logger.info('Added %d new topic(s) to the database', len(new))
  365 +
  366 + # ------------------------------------------------------------------------
  367 + def _db_setup(self, database: str) -> None:
  368 + '''
  369 + Setup and check database contents
  370 + '''
  371 +
  372 + logger.info('Checking database "%s":', database)
  373 + if not exists(database):
354 raise LearnException('Database does not exist. ' 374 raise LearnException('Database does not exist. '
355 'Use "initdb-aprendizations" to create') 375 'Use "initdb-aprendizations" to create')
356 376
357 - engine = sa.create_engine(f'sqlite:///{db}', echo=False)  
358 - self.Session = sa.orm.sessionmaker(bind=engine) 377 + self._engine = create_engine(f'sqlite:///{database}', future=True)
  378 +
  379 +
359 try: 380 try:
360 - with self.db_session() as s:  
361 - n: int = s.query(Student).count()  
362 - m: int = s.query(Topic).count()  
363 - q: int = s.query(Answer).count()  
364 - except Exception:  
365 - logger.error(f'Database "{db}" not usable!')  
366 - raise DatabaseUnusableError() 381 + query_students = select(func.count(Student.id))
  382 + query_topics = select(func.count(Topic.id))
  383 + query_answers = select(func.count(Answer.id))
  384 + with Session(self._engine, future=True) as session:
  385 + count_students = session.execute(query_students).scalar()
  386 + count_topics = session.execute(query_topics).scalar()
  387 + count_answers = session.execute(query_answers).scalar()
  388 + except Exception as exc:
  389 + logger.error('Database "%s" not usable!', database)
  390 + raise DatabaseUnusableError() from exc
367 else: 391 else:
368 - logger.info(f'{n:6} students')  
369 - logger.info(f'{m:6} topics')  
370 - logger.info(f'{q:6} answers') 392 + logger.info('%6d students', count_students)
  393 + logger.info('%6d topics', count_topics)
  394 + logger.info('%6d answers', count_answers)
371 395
372 - # ========================================================================  
373 - # Populates a digraph.  
374 - #  
375 - # Nodes are the topic references e.g. 'my/topic'  
376 - # g.nodes['my/topic']['name'] name of the topic  
377 - # g.nodes['my/topic']['questions'] list of question refs  
378 - #  
379 - # Edges are obtained from the deps defined in the YAML file for each topic.  
380 # ------------------------------------------------------------------------ 396 # ------------------------------------------------------------------------
381 - def populate_graph(self, config: Dict[str, Any]) -> None:  
382 - g = self.deps # dependency graph 397 + def _populate_graph(self, config: Dict[str, Any]) -> None:
  398 + '''
  399 + Populates a digraph.
  400 +
  401 + Nodes are the topic references e.g. 'my/topic'
  402 + g.nodes['my/topic']['name'] name of the topic
  403 + g.nodes['my/topic']['questions'] list of question refs
  404 +
  405 + Edges are obtained from the deps defined in the YAML file for each topic.
  406 + '''
  407 +
383 defaults = { 408 defaults = {
384 - 'type': 'topic', 409 + 'type': 'topic', # chapter
385 'file': 'questions.yaml', 410 'file': 'questions.yaml',
386 'shuffle_questions': True, 411 'shuffle_questions': True,
387 - 'choose': 9999, 412 + 'choose': 99,
388 'forgetting_factor': 1.0, # no forgetting 413 'forgetting_factor': 1.0, # no forgetting
389 'max_tries': 1, # in every question 414 'max_tries': 1, # in every question
390 'append_wrong': True, 415 'append_wrong': True,
@@ -394,20 +419,21 @@ class LearnApp(object): @@ -394,20 +419,21 @@ class LearnApp(object):
394 419
395 # iterate over topics and populate graph 420 # iterate over topics and populate graph
396 topics: Dict[str, Dict] = config.get('topics', {}) 421 topics: Dict[str, Dict] = config.get('topics', {})
397 - g.add_nodes_from(topics.keys()) 422 + self.deps.add_nodes_from(topics.keys())
398 for tref, attr in topics.items(): 423 for tref, attr in topics.items():
399 - logger.debug(f' + {tref}')  
400 - for d in attr.get('deps', []):  
401 - g.add_edge(d, tref) 424 + logger.debug(' + %s', tref)
  425 + for dep in attr.get('deps', []):
  426 + self.deps.add_edge(dep, tref)
402 427
403 - t = g.nodes[tref] # get current topic node  
404 - t['name'] = attr.get('name', tref)  
405 - t['questions'] = attr.get('questions', []) 428 + topic = self.deps.nodes[tref] # get current topic node
  429 + topic['name'] = attr.get('name', tref)
  430 + topic['questions'] = attr.get('questions', []) # FIXME unused??
406 431
407 for k, default in defaults.items(): 432 for k, default in defaults.items():
408 - t[k] = attr.get(k, default) 433 + topic[k] = attr.get(k, default)
409 434
410 - t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic 435 + # prefix/topic
  436 + topic['path'] = join(self.deps.graph['prefix'], tref)
411 437
412 438
413 # ======================================================================== 439 # ========================================================================
@@ -415,48 +441,45 @@ class LearnApp(object): @@ -415,48 +441,45 @@ class LearnApp(object):
415 # ======================================================================== 441 # ========================================================================
416 442
417 # ------------------------------------------------------------------------ 443 # ------------------------------------------------------------------------
418 - # Buils dictionary of question factories  
419 - # - visits each topic in the graph,  
420 - # - adds factory for each topic.  
421 - # ------------------------------------------------------------------------  
422 - def make_factory(self) -> Dict[str, QFactory]: 444 + def _make_factory(self) -> Dict[str, QFactory]:
  445 + '''
  446 + Buils dictionary of question factories
  447 + - visits each topic in the graph,
  448 + - adds factory for each topic.
  449 + '''
423 450
424 logger.info('Building questions factory:') 451 logger.info('Building questions factory:')
425 factory = dict() 452 factory = dict()
426 - g = self.deps  
427 - for tref in g.nodes():  
428 - factory.update(self.factory_for(tref)) 453 + for tref in self.deps.nodes:
  454 + factory.update(self._factory_for(tref))
429 455
430 - logger.info(f'Factory has {len(factory)} questions') 456 + logger.info('Factory has %s questions', len(factory))
431 return factory 457 return factory
432 458
433 # ------------------------------------------------------------------------ 459 # ------------------------------------------------------------------------
434 # makes factory for a single topic 460 # makes factory for a single topic
435 # ------------------------------------------------------------------------ 461 # ------------------------------------------------------------------------
436 - def factory_for(self, tref: str) -> Dict[str, QFactory]: 462 + def _factory_for(self, tref: str) -> Dict[str, QFactory]:
437 factory: Dict[str, QFactory] = dict() 463 factory: Dict[str, QFactory] = dict()
438 - g = self.deps  
439 - t = g.nodes[tref] # get node 464 + topic = self.deps.nodes[tref] # get node
440 # load questions as list of dicts 465 # load questions as list of dicts
441 - topicpath: str = path.join(g.graph['prefix'], tref)  
442 try: 466 try:
443 - fullpath: str = path.join(topicpath, t['file'])  
444 - except Exception:  
445 - msg1 = f'Invalid topic "{tref}"'  
446 - msg2 = f'Check dependencies of: {", ".join(g.successors(tref))}'  
447 - msg = f'{msg1}. {msg2}' 467 + fullpath: str = join(topic['path'], topic['file'])
  468 + except Exception as exc:
  469 + msg = f'Invalid topic "{tref}". Check dependencies of: ' + \
  470 + ', '.join(self.deps.successors(tref))
448 logger.error(msg) 471 logger.error(msg)
449 - raise LearnException(msg)  
450 - logger.debug(f' Loading {fullpath}') 472 + raise LearnException(msg) from exc
  473 + logger.debug(' Loading %s', fullpath)
451 try: 474 try:
452 questions: List[QDict] = load_yaml(fullpath) 475 questions: List[QDict] = load_yaml(fullpath)
453 - except Exception:  
454 - if t['type'] == 'chapter': 476 + except Exception as exc:
  477 + if topic['type'] == 'chapter':
455 return factory # chapters may have no "questions" 478 return factory # chapters may have no "questions"
456 - else:  
457 - msg = f'Failed to load "{fullpath}"'  
458 - logger.error(msg)  
459 - raise LearnException(msg) 479 + msg = f'Failed to load "{fullpath}"'
  480 + logger.error(msg)
  481 + raise LearnException(msg) from exc
  482 +
460 if not isinstance(questions, list): 483 if not isinstance(questions, list):
461 msg = f'File "{fullpath}" must be a list of questions' 484 msg = f'File "{fullpath}" must be a list of questions'
462 logger.error(msg) 485 logger.error(msg)
@@ -467,134 +490,161 @@ class LearnApp(object): @@ -467,134 +490,161 @@ class LearnApp(object):
467 # undefined are set to topic:n, where n is the question number 490 # undefined are set to topic:n, where n is the question number
468 # within the file 491 # within the file
469 localrefs: Set[str] = set() # refs in current file 492 localrefs: Set[str] = set() # refs in current file
470 - for i, q in enumerate(questions):  
471 - qref = q.get('ref', str(i)) # ref or number 493 + for i, question in enumerate(questions):
  494 + qref = question.get('ref', str(i)) # ref or number
472 if qref in localrefs: 495 if qref in localrefs:
473 - msg = f'Duplicate ref "{qref}" in "{topicpath}"' 496 + msg = f'Duplicate ref "{qref}" in "{topic["path"]}"'
  497 + logger.error(msg)
474 raise LearnException(msg) 498 raise LearnException(msg)
475 localrefs.add(qref) 499 localrefs.add(qref)
476 500
477 - q['ref'] = f'{tref}:{qref}'  
478 - q['path'] = topicpath  
479 - q.setdefault('append_wrong', t['append_wrong']) 501 + question['ref'] = f'{tref}:{qref}'
  502 + question['path'] = topic['path']
  503 + question.setdefault('append_wrong', topic['append_wrong'])
480 504
481 # if questions are left undefined, include all. 505 # if questions are left undefined, include all.
482 - if not t['questions']:  
483 - t['questions'] = [q['ref'] for q in questions] 506 + if not topic['questions']:
  507 + topic['questions'] = [q['ref'] for q in questions]
484 508
485 - t['choose'] = min(t['choose'], len(t['questions'])) 509 + topic['choose'] = min(topic['choose'], len(topic['questions']))
486 510
487 - for q in questions:  
488 - if q['ref'] in t['questions']:  
489 - factory[q['ref']] = QFactory(q)  
490 - logger.debug(f' + {q["ref"]}') 511 + for question in questions:
  512 + if question['ref'] in topic['questions']:
  513 + factory[question['ref']] = QFactory(question)
  514 + logger.debug(' + %s', question["ref"])
491 515
492 - logger.info(f'{len(t["questions"]):6} questions in {tref}') 516 + logger.info('%6d questions in %s', len(topic["questions"]), tref)
493 517
494 return factory 518 return factory
495 519
496 # ------------------------------------------------------------------------ 520 # ------------------------------------------------------------------------
497 def get_login_counter(self, uid: str) -> int: 521 def get_login_counter(self, uid: str) -> int:
  522 + '''login counter''' # FIXME
498 return int(self.online[uid]['counter']) 523 return int(self.online[uid]['counter'])
499 524
500 # ------------------------------------------------------------------------ 525 # ------------------------------------------------------------------------
501 def get_student_name(self, uid: str) -> str: 526 def get_student_name(self, uid: str) -> str:
  527 + '''Get the username'''
502 return self.online[uid].get('name', '') 528 return self.online[uid].get('name', '')
503 529
504 # ------------------------------------------------------------------------ 530 # ------------------------------------------------------------------------
505 def get_student_state(self, uid: str) -> List[Dict[str, Any]]: 531 def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
  532 + '''Get the knowledge state of a given user'''
506 return self.online[uid]['state'].get_knowledge_state() 533 return self.online[uid]['state'].get_knowledge_state()
507 534
508 # ------------------------------------------------------------------------ 535 # ------------------------------------------------------------------------
509 def get_student_progress(self, uid: str) -> float: 536 def get_student_progress(self, uid: str) -> float:
  537 + '''Get the current topic progress of a given user'''
510 return float(self.online[uid]['state'].get_topic_progress()) 538 return float(self.online[uid]['state'].get_topic_progress())
511 539
512 # ------------------------------------------------------------------------ 540 # ------------------------------------------------------------------------
513 def get_current_question(self, uid: str) -> Optional[Question]: 541 def get_current_question(self, uid: str) -> Optional[Question]:
514 - q: Optional[Question] = self.online[uid]['state'].get_current_question()  
515 - return q 542 + '''Get the current question of a given user'''
  543 + question: Optional[Question] = self.online[uid]['state'].get_current_question()
  544 + return question
516 545
517 # ------------------------------------------------------------------------ 546 # ------------------------------------------------------------------------
518 def get_current_question_id(self, uid: str) -> str: 547 def get_current_question_id(self, uid: str) -> str:
  548 + '''Get id of the current question for a given user'''
519 return str(self.online[uid]['state'].get_current_question()['qid']) 549 return str(self.online[uid]['state'].get_current_question()['qid'])
520 550
521 # ------------------------------------------------------------------------ 551 # ------------------------------------------------------------------------
522 def get_student_question_type(self, uid: str) -> str: 552 def get_student_question_type(self, uid: str) -> str:
  553 + '''Get type of the current question for a given user'''
523 return str(self.online[uid]['state'].get_current_question()['type']) 554 return str(self.online[uid]['state'].get_current_question()['type'])
524 555
525 # ------------------------------------------------------------------------ 556 # ------------------------------------------------------------------------
526 - def get_student_topic(self, uid: str) -> str:  
527 - return str(self.online[uid]['state'].get_current_topic()) 557 + # def get_student_topic(self, uid: str) -> str:
  558 + # return str(self.online[uid]['state'].get_current_topic())
528 559
529 # ------------------------------------------------------------------------ 560 # ------------------------------------------------------------------------
530 def get_student_course_title(self, uid: str) -> str: 561 def get_student_course_title(self, uid: str) -> str:
  562 + '''get the title of the current course for a given user'''
531 return str(self.online[uid]['state'].get_current_course_title()) 563 return str(self.online[uid]['state'].get_current_course_title())
532 564
533 # ------------------------------------------------------------------------ 565 # ------------------------------------------------------------------------
534 def get_current_course_id(self, uid: str) -> Optional[str]: 566 def get_current_course_id(self, uid: str) -> Optional[str]:
  567 + '''get the current course (id) of a given user'''
535 cid: Optional[str] = self.online[uid]['state'].get_current_course_id() 568 cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
536 return cid 569 return cid
537 570
538 # ------------------------------------------------------------------------ 571 # ------------------------------------------------------------------------
539 - def get_topic_name(self, ref: str) -> str:  
540 - return str(self.deps.nodes[ref]['name']) 572 + # def get_topic_name(self, ref: str) -> str:
  573 + # return str(self.deps.nodes[ref]['name'])
541 574
542 # ------------------------------------------------------------------------ 575 # ------------------------------------------------------------------------
543 def get_current_public_dir(self, uid: str) -> str: 576 def get_current_public_dir(self, uid: str) -> str:
  577 + '''
  578 + Get the path for the 'public' directory of the current topic of the
  579 + given user.
  580 + E.g. if the user has the active topic 'xpto',
  581 + then returns 'path/to/xpto/public'.
  582 + '''
544 topic: str = self.online[uid]['state'].get_current_topic() 583 topic: str = self.online[uid]['state'].get_current_topic()
545 prefix: str = self.deps.graph['prefix'] 584 prefix: str = self.deps.graph['prefix']
546 - return path.join(prefix, topic, 'public') 585 + return join(prefix, topic, 'public')
547 586
548 # ------------------------------------------------------------------------ 587 # ------------------------------------------------------------------------
549 def get_courses(self) -> Dict[str, Dict[str, Any]]: 588 def get_courses(self) -> Dict[str, Dict[str, Any]]:
  589 + '''
  590 + Get dictionary with all courses {'course1': {...}, 'course2': {...}}
  591 + '''
550 return self.courses 592 return self.courses
551 593
552 # ------------------------------------------------------------------------ 594 # ------------------------------------------------------------------------
553 def get_course(self, course_id: str) -> Dict[str, Any]: 595 def get_course(self, course_id: str) -> Dict[str, Any]:
  596 + '''
  597 + Get dictionary {'title': ..., 'description':..., 'goals':...}
  598 + '''
554 return self.courses[course_id] 599 return self.courses[course_id]
555 600
556 # ------------------------------------------------------------------------ 601 # ------------------------------------------------------------------------
557 def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]: 602 def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]:
558 -  
559 - logger.info(f'User "{uid}" get rankings for {course_id}')  
560 - with self.db_session() as s:  
561 - students = s.query(Student.id, Student.name).all()  
562 -  
563 - # topic progress  
564 - student_topics = s.query(StudentTopic.student_id,  
565 - StudentTopic.topic_id,  
566 - StudentTopic.level,  
567 - StudentTopic.date).all() 603 + '''
  604 + Returns rankings for a certain course_id.
  605 + User where uid have <=2 chars are considered ghosts are hidden from
  606 + the rankings. This is so that there can be users for development or
  607 + testing purposes, which are not real users.
  608 + The user_id of real students must have >2 chars.
  609 + This should be modified to have a "visible" flag
  610 + '''
  611 +
  612 + logger.info('User "%s" get rankings for %s', uid, course_id)
  613 + query_students = select(Student.id, Student.name)
  614 + query_student_topics = select(StudentTopic.student_id,
  615 + StudentTopic.topic_id,
  616 + StudentTopic.level,
  617 + StudentTopic.date)
  618 + query_total = select(Answer.student_id, func.count(Answer.ref))
  619 + query_right = select(Answer.student_id, func.count(Answer.ref)).where(Answer.grade == 1.0)
  620 + with Session(self._engine, future=True) as session:
  621 + # all students in the database FIXME only with answers of this course
  622 + students = session.execute(query_students).all()
  623 +
  624 + # topic levels FIXME only topics of this course
  625 + student_topics = session.execute(query_student_topics).all()
568 626
569 # answer performance 627 # answer performance
570 - total = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)).  
571 - group_by(Answer.student_id).  
572 - all())  
573 - right = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)).  
574 - filter(Answer.grade == 1.0).  
575 - group_by(Answer.student_id).  
576 - all()) 628 + total = dict(session.execute(query_total).all())
  629 + right = dict(session.execute(query_right).all())
577 630
578 # compute percentage of right answers 631 # compute percentage of right answers
579 - perf: Dict[str, float] = {u: right.get(u, 0.0)/total[u] 632 + perf: Dict[str, float] = {u: right.get(u, 0.0) / total[u]
580 for u in total} 633 for u in total}
581 634
582 # compute topic progress 635 # compute topic progress
583 now = datetime.now() 636 now = datetime.now()
584 goals = self.courses[course_id]['goals'] 637 goals = self.courses[course_id]['goals']
585 - prog: DefaultDict[str, float] = defaultdict(int) 638 + progress: DefaultDict[str, float] = defaultdict(int)
586 639
587 - for u, topic, level, date in student_topics: 640 + for student, topic, level, date in student_topics:
588 if topic in goals: 641 if topic in goals:
589 date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f") 642 date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f")
590 - prog[u] += level**(now - date).days / len(goals)  
591 -  
592 - ghostuser = len(uid) <= 2 # ghosts are invisible to students  
593 - rankings = [(u, name, prog[u], perf.get(u, 0.0))  
594 - for u, name in students  
595 - if u in prog  
596 - and (len(u) > 2 or ghostuser) and u != '0' ]  
597 - rankings.sort(key=lambda x: x[2], reverse=True)  
598 - return rankings 643 + progress[student] += level**(now - date).days / len(goals)
  644 +
  645 + return sorted(((u, name, progress[u], perf.get(u, 0.0))
  646 + for u, name in students
  647 + if u in progress and (len(u) > 2 or len(uid) <= 2)),
  648 + key=lambda x: x[2], reverse=True)
599 649
600 # ------------------------------------------------------------------------ 650 # ------------------------------------------------------------------------
aprendizations/main.py
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
  3 +'''
  4 +Setup configurations and then runs the application.
  5 +'''
  6 +
  7 +
3 # python standard library 8 # python standard library
4 import argparse 9 import argparse
5 -import logging 10 +import logging.config
6 from os import environ, path 11 from os import environ, path
7 import ssl 12 import ssl
8 import sys 13 import sys
9 from typing import Any, Dict 14 from typing import Any, Dict
10 15
11 # this project 16 # this project
12 -from .learnapp import LearnApp, DatabaseUnusableError, LearnException  
13 -from .serve import run_webserver  
14 -from .tools import load_yaml  
15 -from . import APP_NAME, APP_VERSION 17 +from aprendizations.learnapp import LearnApp, DatabaseUnusableError, LearnException
  18 +from aprendizations.serve import run_webserver
  19 +from aprendizations.tools import load_yaml
  20 +from aprendizations import APP_NAME, APP_VERSION
16 21
17 22
18 # ---------------------------------------------------------------------------- 23 # ----------------------------------------------------------------------------
19 def parse_cmdline_arguments(): 24 def parse_cmdline_arguments():
  25 + '''
  26 + Parses command line arguments. Uses the argparse package.
  27 + '''
  28 +
20 argparser = argparse.ArgumentParser( 29 argparser = argparse.ArgumentParser(
21 - description='Server for online learning. Students and topics '  
22 - 'have to be previously configured. Please read the documentation '  
23 - 'included with this software before running the server.' 30 + description='Webserver for interactive learning and practice. '
  31 + 'Please read the documentation included with this software before '
  32 + 'using it.'
  33 + )
  34 +
  35 + argparser.add_argument(
  36 + 'courses', type=str, nargs='?', default='courses.yaml',
  37 + help='configuration file in YAML format.'
24 ) 38 )
25 39
26 argparser.add_argument( 40 argparser.add_argument(
27 - 'courses', type=str, # nargs='*',  
28 - help='Courses configuration file in YAML format.' 41 + '-v', '--version', action='store_true',
  42 + help='show version information and exit'
29 ) 43 )
30 44
31 argparser.add_argument( 45 argparser.add_argument(
32 '--prefix', type=str, default='.', 46 '--prefix', type=str, default='.',
33 - help='Path where the topic directories can be found (default: .)' 47 + help='path where the topic directories can be found (default: .)'
34 ) 48 )
35 49
36 argparser.add_argument( 50 argparser.add_argument(
37 '--port', type=int, default=8443, 51 '--port', type=int, default=8443,
38 - help='Port to be used by the HTTPS server (default: 8443)' 52 + help='port for the HTTPS server (default: 8443)'
39 ) 53 )
40 54
41 argparser.add_argument( 55 argparser.add_argument(
@@ -44,18 +58,13 @@ def parse_cmdline_arguments(): @@ -44,18 +58,13 @@ def parse_cmdline_arguments():
44 ) 58 )
45 59
46 argparser.add_argument( 60 argparser.add_argument(
47 - '--check', action='store_true',  
48 - help='Sanity check questions (can take awhile)' 61 + '-c', '--check', action='store_true',
  62 + help='sanity check questions (can take awhile)'
49 ) 63 )
50 64
51 argparser.add_argument( 65 argparser.add_argument(
52 '--debug', action='store_true', 66 '--debug', action='store_true',
53 - help='Enable debug mode'  
54 - )  
55 -  
56 - argparser.add_argument(  
57 - '--version', action='store_true',  
58 - help='Print version information' 67 + help='enable debug mode'
59 ) 68 )
60 69
61 return argparser.parse_args() 70 return argparser.parse_args()
@@ -63,6 +72,12 @@ def parse_cmdline_arguments(): @@ -63,6 +72,12 @@ def parse_cmdline_arguments():
63 72
64 # ---------------------------------------------------------------------------- 73 # ----------------------------------------------------------------------------
65 def get_logger_config(debug: bool = False) -> Any: 74 def get_logger_config(debug: bool = False) -> Any:
  75 + '''
  76 + Loads logger configuration in yaml format from a file, otherwise sets up a
  77 + default configuration.
  78 + Returns the configuration.
  79 + '''
  80 +
66 if debug: 81 if debug:
67 filename, level = 'logger-debug.yaml', 'DEBUG' 82 filename, level = 'logger-debug.yaml', 'DEBUG'
68 else: 83 else:
@@ -106,14 +121,16 @@ def get_logger_config(debug: bool = False) -&gt; Any: @@ -106,14 +121,16 @@ def get_logger_config(debug: bool = False) -&gt; Any:
106 121
107 122
108 # ---------------------------------------------------------------------------- 123 # ----------------------------------------------------------------------------
109 -# Start application and webserver  
110 -# ----------------------------------------------------------------------------  
111 def main(): 124 def main():
  125 + '''
  126 + Start application and webserver
  127 + '''
  128 +
112 # --- Commandline argument parsing 129 # --- Commandline argument parsing
113 arg = parse_cmdline_arguments() 130 arg = parse_cmdline_arguments()
114 131
115 if arg.version: 132 if arg.version:
116 - print(f'{APP_NAME} - {APP_VERSION}\nPython {sys.version}') 133 + print(f'{APP_NAME} {APP_VERSION}\nPython {sys.version}')
117 sys.exit(0) 134 sys.exit(0)
118 135
119 # --- Setup logging 136 # --- Setup logging
@@ -122,8 +139,8 @@ def main(): @@ -122,8 +139,8 @@ def main():
122 139
123 try: 140 try:
124 logging.config.dictConfig(logger_config) 141 logging.config.dictConfig(logger_config)
125 - except Exception:  
126 - print('An error ocurred while setting up the logging system.') 142 + except (ValueError, TypeError, AttributeError, ImportError) as exc:
  143 + print('An error ocurred while setting up the logging system: %s', exc)
127 sys.exit(1) 144 sys.exit(1)
128 145
129 logging.info('====================== Start Logging ======================') 146 logging.info('====================== Start Logging ======================')
@@ -139,7 +156,7 @@ def main(): @@ -139,7 +156,7 @@ def main():
139 ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'), 156 ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
140 path.join(certs_dir, 'privkey.pem')) 157 path.join(certs_dir, 'privkey.pem'))
141 except FileNotFoundError: 158 except FileNotFoundError:
142 - logging.critical(f'SSL certificates missing in {certs_dir}') 159 + logging.critical('SSL certificates missing in %s', certs_dir)
143 print('--------------------------------------------------------------', 160 print('--------------------------------------------------------------',
144 'Certificates should be issued by a certificate authority (CA),', 161 'Certificates should be issued by a certificate authority (CA),',
145 'such as https://letsencrypt.org. ', 162 'such as https://letsencrypt.org. ',
@@ -178,12 +195,14 @@ def main(): @@ -178,12 +195,14 @@ def main():
178 '--------------------------------------------------------------', 195 '--------------------------------------------------------------',
179 sep='\n') 196 sep='\n')
180 sys.exit(1) 197 sys.exit(1)
181 - except LearnException as e: 198 + except LearnException as exc:
182 logging.critical('Failed to start backend') 199 logging.critical('Failed to start backend')
183 - sys.exit(1) 200 + # sys.exit(1)
  201 + raise
184 except Exception: 202 except Exception:
185 logging.critical('Unknown error') 203 logging.critical('Unknown error')
186 - sys.exit(1) 204 + # sys.exit(1)
  205 + raise
187 else: 206 else:
188 logging.info('LearnApp started') 207 logging.info('LearnApp started')
189 208
aprendizations/models.py
1 1
2 -# python standard library  
3 -from typing import Any  
4 -  
5 # third party libraries 2 # third party libraries
6 from sqlalchemy import Column, ForeignKey, Integer, Float, String 3 from sqlalchemy import Column, ForeignKey, Integer, Float, String
7 -from sqlalchemy.ext.declarative import declarative_base  
8 -from sqlalchemy.orm import relationship 4 +from sqlalchemy.orm import declarative_base, relationship
9 5
10 6
11 # =========================================================================== 7 # ===========================================================================
12 # Declare ORM 8 # Declare ORM
13 # FIXME Any is a workaround for mypy static type checking (see https://github.com/python/mypy/issues/6372) 9 # FIXME Any is a workaround for mypy static type checking (see https://github.com/python/mypy/issues/6372)
14 -Base: Any = declarative_base() 10 +# from typing import Any
  11 +# Base: Any = declarative_base()
  12 +Base = declarative_base()
15 13
16 14
17 # --------------------------------------------------------------------------- 15 # ---------------------------------------------------------------------------
@@ -27,11 +25,11 @@ class StudentTopic(Base): @@ -27,11 +25,11 @@ class StudentTopic(Base):
27 topic = relationship('Topic', back_populates='students') 25 topic = relationship('Topic', back_populates='students')
28 26
29 def __repr__(self): 27 def __repr__(self):
30 - return f'''StudentTopic:  
31 - student_id: "{self.student_id}"  
32 - topic_id: "{self.topic_id}"  
33 - level: "{self.level}"  
34 - date: "{self.date}"''' 28 + return ('StudentTopic('
  29 + f'student_id={self.student_id!r}, '
  30 + f'topic_id={self.topic_id!r}, '
  31 + f'level={self.level!r}, '
  32 + f'date={self.date!r})')
35 33
36 34
37 # --------------------------------------------------------------------------- 35 # ---------------------------------------------------------------------------
@@ -48,10 +46,10 @@ class Student(Base): @@ -48,10 +46,10 @@ class Student(Base):
48 topics = relationship('StudentTopic', back_populates='student') 46 topics = relationship('StudentTopic', back_populates='student')
49 47
50 def __repr__(self): 48 def __repr__(self):
51 - return f'''Student:  
52 - id: "{self.id}"  
53 - name: "{self.name}"  
54 - password: "{self.password}"''' 49 + return ('Student('
  50 + f'id={self.id!r}, '
  51 + f'name={self.name!r}, '
  52 + f'password={self.password!r})')
55 53
56 54
57 # --------------------------------------------------------------------------- 55 # ---------------------------------------------------------------------------
@@ -72,14 +70,14 @@ class Answer(Base): @@ -72,14 +70,14 @@ class Answer(Base):
72 topic = relationship('Topic', back_populates='answers') 70 topic = relationship('Topic', back_populates='answers')
73 71
74 def __repr__(self): 72 def __repr__(self):
75 - return f'''Question:  
76 - id: "{self.id}"  
77 - ref: "{self.ref}"  
78 - grade: "{self.grade}"  
79 - starttime: "{self.starttime}"  
80 - finishtime: "{self.finishtime}"  
81 - student_id: "{self.student_id}"  
82 - topic_id: "{self.topic_id}"''' 73 + return ('Question('
  74 + f'id={self.id!r}, '
  75 + f'ref={self.ref!r}, '
  76 + f'grade={self.grade!r}, '
  77 + f'starttime={self.starttime!r}, '
  78 + f'finishtime={self.finishtime!r}, '
  79 + f'student_id={self.student_id!r}, '
  80 + f'topic_id={self.topic_id!r})')
83 81
84 82
85 # --------------------------------------------------------------------------- 83 # ---------------------------------------------------------------------------
@@ -94,5 +92,4 @@ class Topic(Base): @@ -94,5 +92,4 @@ class Topic(Base):
94 answers = relationship('Answer', back_populates='topic') 92 answers = relationship('Answer', back_populates='topic')
95 93
96 def __repr__(self): 94 def __repr__(self):
97 - return f'''Topic:  
98 - id: "{self.id}"''' 95 + return f'Topic(id={self.id!r})'
aprendizations/questions.py
  1 +'''
  2 +Classes the implement several types of questions.
  3 +'''
  4 +
1 5
2 # python standard library 6 # python standard library
3 import asyncio 7 import asyncio
4 from datetime import datetime 8 from datetime import datetime
  9 +import logging
  10 +from os import path
5 import random 11 import random
6 import re 12 import re
7 -from os import path  
8 -import logging  
9 from typing import Any, Dict, NewType 13 from typing import Any, Dict, NewType
10 import uuid 14 import uuid
11 15
12 # this project 16 # this project
13 -from .tools import run_script, run_script_async 17 +from aprendizations.tools import run_script, run_script_async
14 18
15 # setup logger for this module 19 # setup logger for this module
16 logger = logging.getLogger(__name__) 20 logger = logging.getLogger(__name__)
17 21
18 -  
19 QDict = NewType('QDict', Dict[str, Any]) 22 QDict = NewType('QDict', Dict[str, Any])
20 23
21 24
22 class QuestionException(Exception): 25 class QuestionException(Exception):
23 - pass 26 + '''Exceptions raised in this module'''
24 27
25 28
26 # ============================================================================ 29 # ============================================================================
@@ -33,8 +36,11 @@ class Question(dict): @@ -33,8 +36,11 @@ class Question(dict):
33 for each student. 36 for each student.
34 Instances can shuffle options or automatically generate questions. 37 Instances can shuffle options or automatically generate questions.
35 ''' 38 '''
36 - def __init__(self, q: QDict) -> None:  
37 - super().__init__(q) 39 +
  40 + def gen(self) -> None:
  41 + '''
  42 + Sets defaults that are valid for any question type
  43 + '''
38 44
39 # add required keys if missing 45 # add required keys if missing
40 self.set_defaults(QDict({ 46 self.set_defaults(QDict({
@@ -46,20 +52,23 @@ class Question(dict): @@ -46,20 +52,23 @@ class Question(dict):
46 })) 52 }))
47 53
48 def set_answer(self, ans) -> None: 54 def set_answer(self, ans) -> None:
  55 + '''set answer field and register time'''
49 self['answer'] = ans 56 self['answer'] = ans
50 self['finish_time'] = datetime.now() 57 self['finish_time'] = datetime.now()
51 58
52 def correct(self) -> None: 59 def correct(self) -> None:
  60 + '''default correction (synchronous version)'''
53 self['comments'] = '' 61 self['comments'] = ''
54 self['grade'] = 0.0 62 self['grade'] = 0.0
55 63
56 async def correct_async(self) -> None: 64 async def correct_async(self) -> None:
  65 + '''default correction (async version)'''
57 self.correct() 66 self.correct()
58 67
59 - def set_defaults(self, d: QDict) -> None:  
60 - 'Add k:v pairs from default dict d for nonexistent keys'  
61 - for k, v in d.items():  
62 - self.setdefault(k, v) 68 + def set_defaults(self, qdict: QDict) -> None:
  69 + '''Add k:v pairs from default dict d for nonexistent keys'''
  70 + for k, val in qdict.items():
  71 + self.setdefault(k, val)
63 72
64 73
65 # ============================================================================ 74 # ============================================================================
@@ -75,79 +84,89 @@ class QuestionRadio(Question): @@ -75,79 +84,89 @@ class QuestionRadio(Question):
75 choose (int) # only used if shuffle=True 84 choose (int) # only used if shuffle=True
76 ''' 85 '''
77 86
78 - # ------------------------------------------------------------------------  
79 - def __init__(self, q: QDict) -> None:  
80 - super().__init__(q)  
81 - 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()
82 try: 93 try:
83 - n = len(self['options'])  
84 - except KeyError:  
85 - msg = f'Missing `options` in radio question. See {self["path"]}'  
86 - raise QuestionException(msg)  
87 - except TypeError:  
88 - msg = f'`options` must be a list. See {self["path"]}'  
89 - raise QuestionException(msg) 94 + nopts = len(self['options'])
  95 + except KeyError as exc:
  96 + msg = f'Missing `options`. In question "{self["ref"]}"'
  97 + logger.error(msg)
  98 + raise QuestionException(msg) from exc
  99 + except TypeError as exc:
  100 + msg = f'`options` must be a list. In question "{self["ref"]}"'
  101 + logger.error(msg)
  102 + raise QuestionException(msg) from exc
90 103
91 self.set_defaults(QDict({ 104 self.set_defaults(QDict({
92 'text': '', 105 'text': '',
93 'correct': 0, 106 'correct': 0,
94 'shuffle': True, 107 'shuffle': True,
95 'discount': True, 108 'discount': True,
96 - 'max_tries': (n + 3) // 4 # 1 try for each 4 options 109 + 'max_tries': (nopts + 3) // 4 # 1 try for each 4 options
97 })) 110 }))
98 111
99 # check correct bounds and convert int to list, 112 # check correct bounds and convert int to list,
100 # e.g. correct: 2 --> correct: [0,0,1,0,0] 113 # e.g. correct: 2 --> correct: [0,0,1,0,0]
101 if isinstance(self['correct'], int): 114 if isinstance(self['correct'], int):
102 - if not (0 <= self['correct'] < n):  
103 - msg = (f'Correct option not in range 0..{n-1} in '  
104 - f'"{self["ref"]}"') 115 + if not 0 <= self['correct'] < nopts:
  116 + msg = (f'`correct` out of range 0..{nopts-1}. '
  117 + f'In question "{self["ref"]}"')
  118 + logger.error(msg)
105 raise QuestionException(msg) 119 raise QuestionException(msg)
106 120
107 self['correct'] = [1.0 if x == self['correct'] else 0.0 121 self['correct'] = [1.0 if x == self['correct'] else 0.0
108 - for x in range(n)] 122 + for x in range(nopts)]
109 123
110 elif isinstance(self['correct'], list): 124 elif isinstance(self['correct'], list):
111 # must match number of options 125 # must match number of options
112 - if len(self['correct']) != n:  
113 - msg = (f'Incompatible sizes: {n} options vs '  
114 - f'{len(self["correct"])} correct in "{self["ref"]}"') 126 + if len(self['correct']) != nopts:
  127 + msg = (f'{nopts} options vs {len(self["correct"])} correct. '
  128 + f'In question "{self["ref"]}"')
  129 + logger.error(msg)
115 raise QuestionException(msg) 130 raise QuestionException(msg)
  131 +
116 # make sure is a list of floats 132 # make sure is a list of floats
117 try: 133 try:
118 self['correct'] = [float(x) for x in self['correct']] 134 self['correct'] = [float(x) for x in self['correct']]
119 - except (ValueError, TypeError):  
120 - msg = (f'Correct list must contain numbers [0.0, 1.0] or '  
121 - f'booleans in "{self["ref"]}"')  
122 - raise QuestionException(msg) 135 + except (ValueError, TypeError) as exc:
  136 + msg = ('`correct` must be list of numbers or booleans.'
  137 + f'In "{self["ref"]}"')
  138 + logger.error(msg)
  139 + raise QuestionException(msg) from exc
123 140
124 # check grade boundaries 141 # check grade boundaries
125 if self['discount'] and not all(0.0 <= x <= 1.0 142 if self['discount'] and not all(0.0 <= x <= 1.0
126 for x in self['correct']): 143 for x in self['correct']):
127 - msg = (f'Correct values must be in the interval [0.0, 1.0] in '  
128 - f'"{self["ref"]}"') 144 + msg = ('`correct` values must be in the interval [0.0, 1.0]. '
  145 + f'In "{self["ref"]}"')
  146 + logger.error(msg)
129 raise QuestionException(msg) 147 raise QuestionException(msg)
130 148
131 # at least one correct option 149 # at least one correct option
132 if all(x < 1.0 for x in self['correct']): 150 if all(x < 1.0 for x in self['correct']):
133 - msg = (f'At least one correct option is required in '  
134 - f'"{self["ref"]}"') 151 + msg = ('At least one correct option is required. '
  152 + f'In "{self["ref"]}"')
  153 + logger.error(msg)
135 raise QuestionException(msg) 154 raise QuestionException(msg)
136 155
137 # If shuffle==false, all options are shown as defined 156 # If shuffle==false, all options are shown as defined
138 # otherwise, select 1 correct and choose a few wrong ones 157 # otherwise, select 1 correct and choose a few wrong ones
139 if self['shuffle']: 158 if self['shuffle']:
140 # lists with indices of right and wrong options 159 # lists with indices of right and wrong options
141 - right = [i for i in range(n) if self['correct'][i] >= 1]  
142 - wrong = [i for i in range(n) if self['correct'][i] < 1] 160 + right = [i for i in range(nopts) if self['correct'][i] >= 1]
  161 + wrong = [i for i in range(nopts) if self['correct'][i] < 1]
143 162
144 self.set_defaults(QDict({'choose': 1+len(wrong)})) 163 self.set_defaults(QDict({'choose': 1+len(wrong)}))
145 164
146 # try to choose 1 correct option 165 # try to choose 1 correct option
147 if right: 166 if right:
148 - r = random.choice(right)  
149 - options = [self['options'][r]]  
150 - correct = [self['correct'][r]] 167 + sel = random.choice(right)
  168 + options = [self['options'][sel]]
  169 + correct = [self['correct'][sel]]
151 else: 170 else:
152 options = [] 171 options = []
153 correct = [] 172 correct = []
@@ -164,20 +183,23 @@ class QuestionRadio(Question): @@ -164,20 +183,23 @@ class QuestionRadio(Question):
164 self['correct'] = [correct[i] for i in perm] 183 self['correct'] = [correct[i] for i in perm]
165 184
166 # ------------------------------------------------------------------------ 185 # ------------------------------------------------------------------------
167 - # can assign negative grades for wrong answers  
168 def correct(self) -> None: 186 def correct(self) -> None:
  187 + '''
  188 + Correct `answer` and set `grade`.
  189 + Can assign negative grades for wrong answers
  190 + '''
169 super().correct() 191 super().correct()
170 192
171 if self['answer'] is not None: 193 if self['answer'] is not None:
172 - x = self['correct'][int(self['answer'])] # get grade of the answer  
173 - n = len(self['options'])  
174 - x_aver = sum(self['correct']) / n # expected value of grade 194 + grade = self['correct'][int(self['answer'])] # grade of the answer
  195 + nopts = len(self['options'])
  196 + grade_aver = sum(self['correct']) / nopts # expected value
175 197
176 # note: there are no numerical errors when summing 1.0s so the 198 # note: there are no numerical errors when summing 1.0s so the
177 # x_aver can be exactly 1.0 if all options are right 199 # x_aver can be exactly 1.0 if all options are right
178 - if self['discount'] and x_aver != 1.0:  
179 - x = (x - x_aver) / (1.0 - x_aver)  
180 - self['grade'] = float(x) 200 + if self['discount'] and grade_aver != 1.0:
  201 + grade = (grade - grade_aver) / (1.0 - grade_aver)
  202 + self['grade'] = grade
181 203
182 204
183 # ============================================================================ 205 # ============================================================================
@@ -194,83 +216,77 @@ class QuestionCheckbox(Question): @@ -194,83 +216,77 @@ class QuestionCheckbox(Question):
194 ''' 216 '''
195 217
196 # ------------------------------------------------------------------------ 218 # ------------------------------------------------------------------------
197 - def __init__(self, q: QDict) -> None:  
198 - super().__init__(q) 219 + def gen(self) -> None:
  220 + super().gen()
199 221
200 try: 222 try:
201 - n = len(self['options'])  
202 - except KeyError:  
203 - msg = f'Missing `options` in radio question. See {self["path"]}'  
204 - raise QuestionException(msg)  
205 - except TypeError:  
206 - msg = f'`options` must be a list. See {self["path"]}'  
207 - raise QuestionException(msg) 223 + nopts = len(self['options'])
  224 + except KeyError as exc:
  225 + msg = f'Missing `options`. In question "{self["ref"]}"'
  226 + logger.error(msg)
  227 + raise QuestionException(msg) from exc
  228 + except TypeError as exc:
  229 + msg = f'`options` must be a list. In question "{self["ref"]}"'
  230 + logger.error(msg)
  231 + raise QuestionException(msg) from exc
208 232
209 # set defaults if missing 233 # set defaults if missing
210 self.set_defaults(QDict({ 234 self.set_defaults(QDict({
211 'text': '', 235 'text': '',
212 - 'correct': [1.0] * n, # Using 0.0 breaks (right, wrong) options 236 + 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong)
213 'shuffle': True, 237 'shuffle': True,
214 'discount': True, 238 'discount': True,
215 - 'choose': n, # number of options  
216 - 'max_tries': max(1, min(n - 1, 3)) 239 + 'choose': nopts, # number of options
  240 + 'max_tries': max(1, min(nopts - 1, 3))
217 })) 241 }))
218 242
219 # must be a list of numbers 243 # must be a list of numbers
220 if not isinstance(self['correct'], list): 244 if not isinstance(self['correct'], list):
221 msg = 'Correct must be a list of numbers or booleans' 245 msg = 'Correct must be a list of numbers or booleans'
  246 + logger.error(msg)
222 raise QuestionException(msg) 247 raise QuestionException(msg)
223 248
224 # must match number of options 249 # must match number of options
225 - if len(self['correct']) != n:  
226 - msg = (f'Incompatible sizes: {n} options vs '  
227 - f'{len(self["correct"])} correct in "{self["ref"]}"') 250 + if len(self['correct']) != nopts:
  251 + msg = (f'{nopts} options vs {len(self["correct"])} correct. '
  252 + f'In question "{self["ref"]}"')
  253 + logger.error(msg)
228 raise QuestionException(msg) 254 raise QuestionException(msg)
229 255
230 # make sure is a list of floats 256 # make sure is a list of floats
231 try: 257 try:
232 self['correct'] = [float(x) for x in self['correct']] 258 self['correct'] = [float(x) for x in self['correct']]
233 - except (ValueError, TypeError):  
234 - msg = (f'Correct list must contain numbers or '  
235 - f'booleans in "{self["ref"]}"')  
236 - raise QuestionException(msg) 259 + except (ValueError, TypeError) as exc:
  260 + msg = ('`correct` must be list of numbers or booleans.'
  261 + f'In "{self["ref"]}"')
  262 + logger.error(msg)
  263 + raise QuestionException(msg) from exc
237 264
238 # check grade boundaries 265 # check grade boundaries
239 if self['discount'] and not all(0.0 <= x <= 1.0 266 if self['discount'] and not all(0.0 <= x <= 1.0
240 for x in self['correct']): 267 for x in self['correct']):
241 -  
242 - msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+')  
243 - msg1 = ('| Correct values in checkbox questions must be in the |')  
244 - msg2 = ('| interval [0.0, 1.0]. I will convert to the new |')  
245 - msg3 = ('| behavior, for now, but you should fix it. |')  
246 - msg4 = ('+------------------------------------------------------+')  
247 - logger.warning(msg0)  
248 - logger.warning(msg1)  
249 - logger.warning(msg2)  
250 - logger.warning(msg3)  
251 - logger.warning(msg4)  
252 - logger.warning(f'please fix "{self["ref"]}"')  
253 -  
254 - # normalize to [0,1]  
255 - self['correct'] = [(x+1)/2 for x in self['correct']] 268 + msg = ('values in the `correct` field of checkboxes must be in '
  269 + 'the [0.0, 1.0] interval. '
  270 + f'Please fix "{self["ref"]}" in "{self["path"]}"')
  271 + logger.error(msg)
  272 + raise QuestionException(msg)
256 273
257 # if an option is a list of (right, wrong), pick one 274 # if an option is a list of (right, wrong), pick one
258 options = [] 275 options = []
259 correct = [] 276 correct = []
260 - for o, c in zip(self['options'], self['correct']):  
261 - if isinstance(o, list):  
262 - r = random.randint(0, 1)  
263 - o = o[r]  
264 - if r == 1:  
265 - # c = -c  
266 - c = 1.0 - c  
267 - options.append(str(o))  
268 - correct.append(c) 277 + for option, corr in zip(self['options'], self['correct']):
  278 + if isinstance(option, list):
  279 + sel = random.randint(0, 1)
  280 + option = option[sel]
  281 + if sel == 1:
  282 + corr = 1.0 - corr
  283 + options.append(str(option))
  284 + correct.append(corr)
269 285
270 # generate random permutation, e.g. [2,1,4,0,3] 286 # generate random permutation, e.g. [2,1,4,0,3]
271 # and apply to `options` and `correct` 287 # and apply to `options` and `correct`
272 if self['shuffle']: 288 if self['shuffle']:
273 - perm = random.sample(range(n), k=self['choose']) 289 + perm = random.sample(range(nopts), k=self['choose'])
274 self['options'] = [options[i] for i in perm] 290 self['options'] = [options[i] for i in perm]
275 self['correct'] = [correct[i] for i in perm] 291 self['correct'] = [correct[i] for i in perm]
276 else: 292 else:
@@ -283,18 +299,18 @@ class QuestionCheckbox(Question): @@ -283,18 +299,18 @@ class QuestionCheckbox(Question):
283 super().correct() 299 super().correct()
284 300
285 if self['answer'] is not None: 301 if self['answer'] is not None:
286 - x = 0.0 302 + grade = 0.0
287 if self['discount']: 303 if self['discount']:
288 sum_abs = sum(abs(2*p-1) for p in self['correct']) 304 sum_abs = sum(abs(2*p-1) for p in self['correct'])
289 - for i, p in enumerate(self['correct']):  
290 - x += 2*p-1 if str(i) in self['answer'] else 1-2*p 305 + for i, pts in enumerate(self['correct']):
  306 + grade += 2*pts-1 if str(i) in self['answer'] else 1-2*pts
291 else: 307 else:
292 sum_abs = sum(abs(p) for p in self['correct']) 308 sum_abs = sum(abs(p) for p in self['correct'])
293 - for i, p in enumerate(self['correct']):  
294 - x += p if str(i) in self['answer'] else 0.0 309 + for i, pts in enumerate(self['correct']):
  310 + grade += pts if str(i) in self['answer'] else 0.0
295 311
296 try: 312 try:
297 - self['grade'] = x / sum_abs 313 + self['grade'] = grade / sum_abs
298 except ZeroDivisionError: 314 except ZeroDivisionError:
299 self['grade'] = 1.0 # limit p->0 315 self['grade'] = 1.0 # limit p->0
300 316
@@ -309,9 +325,8 @@ class QuestionText(Question): @@ -309,9 +325,8 @@ class QuestionText(Question):
309 ''' 325 '''
310 326
311 # ------------------------------------------------------------------------ 327 # ------------------------------------------------------------------------
312 - def __init__(self, q: QDict) -> None:  
313 - super().__init__(q)  
314 - 328 + def gen(self) -> None:
  329 + super().gen()
315 self.set_defaults(QDict({ 330 self.set_defaults(QDict({
316 'text': '', 331 'text': '',
317 'correct': [], # no correct answers, always wrong 332 'correct': [], # no correct answers, always wrong
@@ -325,33 +340,36 @@ class QuestionText(Question): @@ -325,33 +340,36 @@ class QuestionText(Question):
325 # make sure all elements of the list are strings 340 # make sure all elements of the list are strings
326 self['correct'] = [str(a) for a in self['correct']] 341 self['correct'] = [str(a) for a in self['correct']]
327 342
328 - for f in self['transform']:  
329 - if f not in ('remove_space', 'trim', 'normalize_space', 'lower',  
330 - 'upper'):  
331 - msg = (f'Unknown transform "{f}" in "{self["ref"]}"') 343 + for transform in self['transform']:
  344 + if transform not in ('remove_space', 'trim', 'normalize_space',
  345 + 'lower', 'upper'):
  346 + msg = (f'Unknown transform "{transform}" in "{self["ref"]}"')
332 raise QuestionException(msg) 347 raise QuestionException(msg)
333 348
334 # check if answers are invariant with respect to the transforms 349 # check if answers are invariant with respect to the transforms
335 if any(c != self.transform(c) for c in self['correct']): 350 if any(c != self.transform(c) for c in self['correct']):
336 - logger.warning(f'in "{self["ref"]}", correct answers are not '  
337 - 'invariant wrt transformations => never correct') 351 + logger.warning('in "%s", correct answers are not invariant wrt '
  352 + 'transformations => never correct', self["ref"])
338 353
339 # ------------------------------------------------------------------------ 354 # ------------------------------------------------------------------------
340 - # apply optional filters to the answer  
341 - def transform(self, ans):  
342 - for f in self['transform']:  
343 - if f == 'remove_space': # removes all spaces  
344 - ans = ans.replace(' ', '')  
345 - elif f == 'trim': # removes spaces around 355 + def transform(self, ans: str):
  356 + '''apply optional filters to the answer'''
  357 +
  358 + # apply transformations in sequence
  359 + for transform in self['transform']:
  360 + if transform == 'remove_space': # removes all spaces
  361 + ans = re.sub(r'\s+', '', ans)
  362 + elif transform == 'trim': # removes spaces around
346 ans = ans.strip() 363 ans = ans.strip()
347 - elif f == 'normalize_space': # replaces multiple spaces by one 364 + elif transform == 'normalize_space': # replaces multiple spaces by one
348 ans = re.sub(r'\s+', ' ', ans.strip()) 365 ans = re.sub(r'\s+', ' ', ans.strip())
349 - elif f == 'lower': # convert to lowercase 366 + elif transform == 'lower': # convert to lowercase
350 ans = ans.lower() 367 ans = ans.lower()
351 - elif f == 'upper': # convert to uppercase 368 + elif transform == 'upper': # convert to uppercase
352 ans = ans.upper() 369 ans = ans.upper()
353 else: 370 else:
354 - logger.warning(f'in "{self["ref"]}", unknown transform "{f}"') 371 + logger.warning('in "%s", unknown transform "%s"',
  372 + self["ref"], transform)
355 return ans 373 return ans
356 374
357 # ------------------------------------------------------------------------ 375 # ------------------------------------------------------------------------
@@ -359,7 +377,7 @@ class QuestionText(Question): @@ -359,7 +377,7 @@ class QuestionText(Question):
359 super().correct() 377 super().correct()
360 378
361 if self['answer'] is not None: 379 if self['answer'] is not None:
362 - answer = self.transform(self['answer']) # apply transformations 380 + answer = self.transform(self['answer'])
363 self['grade'] = 1.0 if answer in self['correct'] else 0.0 381 self['grade'] = 1.0 if answer in self['correct'] else 0.0
364 382
365 383
@@ -376,8 +394,8 @@ class QuestionTextRegex(Question): @@ -376,8 +394,8 @@ class QuestionTextRegex(Question):
376 ''' 394 '''
377 395
378 # ------------------------------------------------------------------------ 396 # ------------------------------------------------------------------------
379 - def __init__(self, q: QDict) -> None:  
380 - super().__init__(q) 397 + def gen(self) -> None:
  398 + super().gen()
381 399
382 self.set_defaults(QDict({ 400 self.set_defaults(QDict({
383 'text': '', 401 'text': '',
@@ -388,27 +406,19 @@ class QuestionTextRegex(Question): @@ -388,27 +406,19 @@ class QuestionTextRegex(Question):
388 if not isinstance(self['correct'], list): 406 if not isinstance(self['correct'], list):
389 self['correct'] = [self['correct']] 407 self['correct'] = [self['correct']]
390 408
391 - # converts patterns to compiled versions  
392 - try:  
393 - self['correct'] = [re.compile(a) for a in self['correct']]  
394 - except Exception:  
395 - msg = f'Failed to compile regex in "{self["ref"]}"'  
396 - raise QuestionException(msg)  
397 -  
398 # ------------------------------------------------------------------------ 409 # ------------------------------------------------------------------------
399 def correct(self) -> None: 410 def correct(self) -> None:
400 super().correct() 411 super().correct()
401 if self['answer'] is not None: 412 if self['answer'] is not None:
402 - self['grade'] = 0.0  
403 - for r in self['correct']: 413 + for regex in self['correct']:
404 try: 414 try:
405 - if r.match(self['answer']): 415 + if re.fullmatch(regex, self['answer']):
406 self['grade'] = 1.0 416 self['grade'] = 1.0
407 return 417 return
408 except TypeError: 418 except TypeError:
409 - logger.error(f'While matching regex {r.pattern} with '  
410 - f'answer "{self["answer"]}".')  
411 - 419 + logger.error('While matching regex "%s" with answer "%s".',
  420 + regex, self['answer'])
  421 + self['grade'] = 0.0
412 422
413 # ============================================================================ 423 # ============================================================================
414 class QuestionNumericInterval(Question): 424 class QuestionNumericInterval(Question):
@@ -421,8 +431,8 @@ class QuestionNumericInterval(Question): @@ -421,8 +431,8 @@ class QuestionNumericInterval(Question):
421 ''' 431 '''
422 432
423 # ------------------------------------------------------------------------ 433 # ------------------------------------------------------------------------
424 - def __init__(self, q: QDict) -> None:  
425 - super().__init__(q) 434 + def gen(self) -> None:
  435 + super().gen()
426 436
427 self.set_defaults(QDict({ 437 self.set_defaults(QDict({
428 'text': '', 438 'text': '',
@@ -438,19 +448,22 @@ class QuestionNumericInterval(Question): @@ -438,19 +448,22 @@ class QuestionNumericInterval(Question):
438 if len(self['correct']) != 2: 448 if len(self['correct']) != 2:
439 msg = (f'Numeric interval must be a list with two numbers, in ' 449 msg = (f'Numeric interval must be a list with two numbers, in '
440 f'{self["ref"]}') 450 f'{self["ref"]}')
  451 + logger.error(msg)
441 raise QuestionException(msg) 452 raise QuestionException(msg)
442 453
443 try: 454 try:
444 self['correct'] = [float(n) for n in self['correct']] 455 self['correct'] = [float(n) for n in self['correct']]
445 - except Exception: 456 + except Exception as exc:
446 msg = (f'Numeric interval must be a list with two numbers, in ' 457 msg = (f'Numeric interval must be a list with two numbers, in '
447 f'{self["ref"]}') 458 f'{self["ref"]}')
448 - raise QuestionException(msg) 459 + logger.error(msg)
  460 + raise QuestionException(msg) from exc
449 461
450 # invalid 462 # invalid
451 else: 463 else:
452 msg = (f'Numeric interval must be a list with two numbers, in ' 464 msg = (f'Numeric interval must be a list with two numbers, in '
453 f'{self["ref"]}') 465 f'{self["ref"]}')
  466 + logger.error(msg)
454 raise QuestionException(msg) 467 raise QuestionException(msg)
455 468
456 # ------------------------------------------------------------------------ 469 # ------------------------------------------------------------------------
@@ -479,8 +492,8 @@ class QuestionTextArea(Question): @@ -479,8 +492,8 @@ class QuestionTextArea(Question):
479 ''' 492 '''
480 493
481 # ------------------------------------------------------------------------ 494 # ------------------------------------------------------------------------
482 - def __init__(self, q: QDict) -> None:  
483 - super().__init__(q) 495 + def gen(self) -> None:
  496 + super().gen()
484 497
485 self.set_defaults(QDict({ 498 self.set_defaults(QDict({
486 'text': '', 499 'text': '',
@@ -504,21 +517,22 @@ class QuestionTextArea(Question): @@ -504,21 +517,22 @@ class QuestionTextArea(Question):
504 ) 517 )
505 518
506 if out is None: 519 if out is None:
507 - logger.warning(f'No grade after running "{self["correct"]}".') 520 + logger.warning('No grade after running "%s".', self["correct"])
  521 + self['comments'] = 'O programa de correcção abortou...'
508 self['grade'] = 0.0 522 self['grade'] = 0.0
509 elif isinstance(out, dict): 523 elif isinstance(out, dict):
510 self['comments'] = out.get('comments', '') 524 self['comments'] = out.get('comments', '')
511 try: 525 try:
512 self['grade'] = float(out['grade']) 526 self['grade'] = float(out['grade'])
513 except ValueError: 527 except ValueError:
514 - logger.error(f'Output error in "{self["correct"]}".') 528 + logger.error('Output error in "%s".', self["correct"])
515 except KeyError: 529 except KeyError:
516 - logger.error(f'No grade in "{self["correct"]}".') 530 + logger.error('No grade in "%s".', self["correct"])
517 else: 531 else:
518 try: 532 try:
519 self['grade'] = float(out) 533 self['grade'] = float(out)
520 except (TypeError, ValueError): 534 except (TypeError, ValueError):
521 - logger.error(f'Invalid grade in "{self["correct"]}".') 535 + logger.error('Invalid grade in "%s".', self["correct"])
522 536
523 # ------------------------------------------------------------------------ 537 # ------------------------------------------------------------------------
524 async def correct_async(self) -> None: 538 async def correct_async(self) -> None:
@@ -533,28 +547,34 @@ class QuestionTextArea(Question): @@ -533,28 +547,34 @@ class QuestionTextArea(Question):
533 ) 547 )
534 548
535 if out is None: 549 if out is None:
536 - logger.warning(f'No grade after running "{self["correct"]}".') 550 + logger.warning('No grade after running "%s".', self["correct"])
  551 + self['comments'] = 'O programa de correcção abortou...'
537 self['grade'] = 0.0 552 self['grade'] = 0.0
538 elif isinstance(out, dict): 553 elif isinstance(out, dict):
539 self['comments'] = out.get('comments', '') 554 self['comments'] = out.get('comments', '')
540 try: 555 try:
541 self['grade'] = float(out['grade']) 556 self['grade'] = float(out['grade'])
542 except ValueError: 557 except ValueError:
543 - logger.error(f'Output error in "{self["correct"]}".') 558 + logger.error('Output error in "%s".', self["correct"])
544 except KeyError: 559 except KeyError:
545 - logger.error(f'No grade in "{self["correct"]}".') 560 + logger.error('No grade in "%s".', self["correct"])
546 else: 561 else:
547 try: 562 try:
548 self['grade'] = float(out) 563 self['grade'] = float(out)
549 except (TypeError, ValueError): 564 except (TypeError, ValueError):
550 - logger.error(f'Invalid grade in "{self["correct"]}".') 565 + logger.error('Invalid grade in "%s".', self["correct"])
551 566
552 567
553 # ============================================================================ 568 # ============================================================================
554 class QuestionInformation(Question): 569 class QuestionInformation(Question):
  570 + '''
  571 + Not really a question, just an information panel.
  572 + The correction is always right.
  573 + '''
  574 +
555 # ------------------------------------------------------------------------ 575 # ------------------------------------------------------------------------
556 - def __init__(self, q: QDict) -> None:  
557 - super().__init__(q) 576 + def gen(self) -> None:
  577 + super().gen()
558 self.set_defaults(QDict({ 578 self.set_defaults(QDict({
559 'text': '', 579 'text': '',
560 })) 580 }))
@@ -566,41 +586,11 @@ class QuestionInformation(Question): @@ -566,41 +586,11 @@ class QuestionInformation(Question):
566 586
567 587
568 # ============================================================================ 588 # ============================================================================
569 -#  
570 -# QFactory is a class that can generate question instances, e.g. by shuffling  
571 -# options, running a script to generate the question, etc.  
572 -#  
573 -# To generate an instance of a question we use the method generate().  
574 -# It returns a question instance of the correct class.  
575 -# There is also an asynchronous version called gen_async(). This version is  
576 -# synchronous for all question types (radio, checkbox, etc) except for  
577 -# generator types which run asynchronously.  
578 -#  
579 -# Example:  
580 -#  
581 -# # make a factory for a question  
582 -# qfactory = QFactory({  
583 -# 'type': 'radio',  
584 -# 'text': 'Choose one',  
585 -# 'options': ['a', 'b']  
586 -# })  
587 -#  
588 -# # generate synchronously  
589 -# question = qfactory.generate()  
590 -#  
591 -# # generate asynchronously  
592 -# question = await qfactory.gen_async()  
593 -#  
594 -# # answer one question and correct it  
595 -# question['answer'] = 42 # set answer  
596 -# question.correct() # correct answer  
597 -# grade = question['grade'] # get grade  
598 -#  
599 -# ============================================================================  
600 -class QFactory(object):  
601 - # Depending on the type of question, a different question class will be  
602 - # instantiated. All these classes derive from the base class `Question`.  
603 - _types = { 589 +def question_from(qdict: QDict) -> Question:
  590 + '''
  591 + Converts a question specified in a dict into an instance of Question()
  592 + '''
  593 + types = {
604 'radio': QuestionRadio, 594 'radio': QuestionRadio,
605 'checkbox': QuestionCheckbox, 595 'checkbox': QuestionCheckbox,
606 'text': QuestionText, 596 'text': QuestionText,
@@ -614,48 +604,92 @@ class QFactory(object): @@ -614,48 +604,92 @@ class QFactory(object):
614 'alert': QuestionInformation, 604 'alert': QuestionInformation,
615 } 605 }
616 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 +# ============================================================================
  627 +class QFactory():
  628 + '''
  629 + QFactory is a class that can generate question instances, e.g. by shuffling
  630 + options, running a script to generate the question, etc.
  631 +
  632 + To generate an instance of a question we use the method generate().
  633 + It returns a question instance of the correct class.
  634 + There is also an asynchronous version called gen_async(). This version is
  635 + synchronous for all question types (radio, checkbox, etc) except for
  636 + generator types which run asynchronously.
  637 +
  638 + Example:
  639 +
  640 + # make a factory for a question
  641 + qfactory = QFactory({
  642 + 'type': 'radio',
  643 + 'text': 'Choose one',
  644 + 'options': ['a', 'b']
  645 + })
  646 +
  647 + # generate synchronously
  648 + question = qfactory.generate()
  649 +
  650 + # generate asynchronously
  651 + question = await qfactory.gen_async()
  652 +
  653 + # answer one question and correct it
  654 + question['answer'] = 42 # set answer
  655 + question.correct() # correct answer
  656 + grade = question['grade'] # get grade
  657 + '''
  658 +
617 def __init__(self, qdict: QDict = QDict({})) -> None: 659 def __init__(self, qdict: QDict = QDict({})) -> None:
618 - self.question = qdict 660 + self.qdict = qdict
619 661
620 # ------------------------------------------------------------------------ 662 # ------------------------------------------------------------------------
621 - # generates a question instance of QuestionRadio, QuestionCheckbox, ...,  
622 - # which is a descendent of base class Question.  
623 - # ------------------------------------------------------------------------  
624 async def gen_async(self) -> Question: 663 async def gen_async(self) -> Question:
625 - logger.debug(f'generating {self.question["ref"]}...') 664 + '''
  665 + generates a question instance of QuestionRadio, QuestionCheckbox, ...,
  666 + which is a descendent of base class Question.
  667 + '''
  668 +
  669 + logger.debug('generating %s...', self.qdict["ref"])
626 # Shallow copy so that script generated questions will not replace 670 # Shallow copy so that script generated questions will not replace
627 # the original generators 671 # the original generators
628 - q = self.question.copy()  
629 - q['qid'] = str(uuid.uuid4()) # unique for each generated question 672 + qdict = QDict(self.qdict.copy())
  673 + qdict['qid'] = str(uuid.uuid4()) # unique for each question
630 674
631 # If question is of generator type, an external program will be run 675 # If question is of generator type, an external program will be run
632 # which will print a valid question in yaml format to stdout. This 676 # which will print a valid question in yaml format to stdout. This
633 # output is then yaml parsed into a dictionary `q`. 677 # output is then yaml parsed into a dictionary `q`.
634 - if q['type'] == 'generator':  
635 - logger.debug(f' \\_ Running "{q["script"]}".')  
636 - q.setdefault('args', [])  
637 - q.setdefault('stdin', '')  
638 - script = path.join(q['path'], q['script'])  
639 - out = await run_script_async(script=script, args=q['args'],  
640 - stdin=q['stdin'])  
641 - q.update(out)  
642 -  
643 - # Get class for this question type  
644 - try:  
645 - qclass = self._types[q['type']]  
646 - except KeyError:  
647 - logger.error(f'Invalid type "{q["type"]}" in "{q["ref"]}"')  
648 - raise  
649 -  
650 - # Finally create an instance of Question()  
651 - try:  
652 - qinstance = qclass(QDict(q))  
653 - except QuestionException as e:  
654 - # logger.error(e)  
655 - raise e  
656 -  
657 - return qinstance 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'])
  683 + out = await run_script_async(script=script,
  684 + args=qdict['args'],
  685 + stdin=qdict['stdin'])
  686 + qdict.update(out)
  687 +
  688 + question = question_from(qdict) # returns a Question instance
  689 + question.gen()
  690 + return question
658 691
659 # ------------------------------------------------------------------------ 692 # ------------------------------------------------------------------------
660 def generate(self) -> Question: 693 def generate(self) -> Question:
  694 + '''generate question (synchronous version)'''
661 return asyncio.get_event_loop().run_until_complete(self.gen_async()) 695 return asyncio.get_event_loop().run_until_complete(self.gen_async())
aprendizations/serve.py
  1 +'''
  2 +Webserver
  3 +'''
  4 +
1 5
2 # python standard library 6 # python standard library
3 import asyncio 7 import asyncio
4 import base64 8 import base64
5 import functools 9 import functools
6 -import logging.config 10 +import logging
7 import mimetypes 11 import mimetypes
8 -from os import path 12 +from os.path import join, dirname, expanduser
9 import signal 13 import signal
10 import sys 14 import sys
11 from typing import List, Optional, Union 15 from typing import List, Optional, Union
12 import uuid 16 import uuid
13 17
14 # third party libraries 18 # third party libraries
  19 +import tornado.httpserver
  20 +import tornado.ioloop
15 import tornado.web 21 import tornado.web
16 from tornado.escape import to_unicode 22 from tornado.escape import to_unicode
17 23
18 # this project 24 # this project
19 -from .tools import md_to_html  
20 -from . import APP_NAME 25 +from aprendizations.tools import md_to_html
  26 +from aprendizations.learnapp import LearnException
  27 +from aprendizations import APP_NAME
21 28
22 29
23 # setup logger for this module 30 # setup logger for this module
@@ -25,39 +32,39 @@ logger = logging.getLogger(__name__) @@ -25,39 +32,39 @@ logger = logging.getLogger(__name__)
25 32
26 33
27 # ---------------------------------------------------------------------------- 34 # ----------------------------------------------------------------------------
28 -# Decorator used to restrict access to the administrator  
29 -# ----------------------------------------------------------------------------  
30 def admin_only(func): 35 def admin_only(func):
  36 + '''
  37 + Decorator used to restrict access to the administrator
  38 + '''
31 @functools.wraps(func) 39 @functools.wraps(func)
32 def wrapper(self, *args, **kwargs): 40 def wrapper(self, *args, **kwargs):
33 if self.current_user != '0': 41 if self.current_user != '0':
34 raise tornado.web.HTTPError(403) # forbidden 42 raise tornado.web.HTTPError(403) # forbidden
35 - else:  
36 - func(self, *args, **kwargs) 43 + func(self, *args, **kwargs)
37 return wrapper 44 return wrapper
38 45
39 46
40 # ============================================================================ 47 # ============================================================================
41 -# WebApplication - Tornado Web Server  
42 -# ============================================================================  
43 class WebApplication(tornado.web.Application): 48 class WebApplication(tornado.web.Application):
44 - 49 + '''
  50 + WebApplication - Tornado Web Server
  51 + '''
45 def __init__(self, learnapp, debug=False): 52 def __init__(self, learnapp, debug=False):
46 handlers = [ 53 handlers = [
47 - (r'/login', LoginHandler),  
48 - (r'/logout', LogoutHandler), 54 + (r'/login', LoginHandler),
  55 + (r'/logout', LogoutHandler),
49 (r'/change_password', ChangePasswordHandler), 56 (r'/change_password', ChangePasswordHandler),
50 - (r'/question', QuestionHandler), # render question  
51 - (r'/rankings', RankingsHandler), # rankings table  
52 - (r'/topic/(.+)', TopicHandler), # start topic  
53 - (r'/file/(.+)', FileHandler), # serve file  
54 - (r'/courses', CoursesHandler), # show list of courses  
55 - (r'/course/(.*)', CourseHandler), # show course topics  
56 - (r'/', RootHandler), # redirects 57 + (r'/question', QuestionHandler), # render question
  58 + (r'/rankings', RankingsHandler), # rankings table
  59 + (r'/topic/(.+)', TopicHandler), # start topic
  60 + (r'/file/(.+)', FileHandler), # serve file
  61 + (r'/courses', CoursesHandler), # show list of courses
  62 + (r'/course/(.*)', CourseHandler), # show course topics
  63 + (r'/', RootHandler), # redirects
57 ] 64 ]
58 settings = { 65 settings = {
59 - 'template_path': path.join(path.dirname(__file__), 'templates'),  
60 - 'static_path': path.join(path.dirname(__file__), 'static'), 66 + 'template_path': join(dirname(__file__), 'templates'),
  67 + 'static_path': join(dirname(__file__), 'static'),
61 'static_url_prefix': '/static/', 68 'static_url_prefix': '/static/',
62 'xsrf_cookies': True, 69 'xsrf_cookies': True,
63 'cookie_secret': base64.b64encode(uuid.uuid4().bytes), 70 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
@@ -71,30 +78,40 @@ class WebApplication(tornado.web.Application): @@ -71,30 +78,40 @@ class WebApplication(tornado.web.Application):
71 # ============================================================================ 78 # ============================================================================
72 # Handlers 79 # Handlers
73 # ============================================================================ 80 # ============================================================================
74 -  
75 -# ----------------------------------------------------------------------------  
76 -# Base handler common to all handlers.  
77 -# ---------------------------------------------------------------------------- 81 +# pylint: disable=abstract-method
78 class BaseHandler(tornado.web.RequestHandler): 82 class BaseHandler(tornado.web.RequestHandler):
  83 + '''
  84 + Base handler common to all handlers.
  85 + '''
79 @property 86 @property
80 def learn(self): 87 def learn(self):
  88 + '''easier access to learnapp'''
81 return self.application.learn 89 return self.application.learn
82 90
83 def get_current_user(self): 91 def get_current_user(self):
84 - cookie = self.get_secure_cookie('user')  
85 - if cookie:  
86 - uid = cookie.decode('utf-8')  
87 - counter = self.get_secure_cookie('counter').decode('utf-8')  
88 - if counter == str(self.learn.get_login_counter(uid)):  
89 - return uid 92 + '''called on every method decorated with @tornado.web.authenticated'''
  93 + user_cookie = self.get_secure_cookie('aprendizations_user')
  94 + counter_cookie = self.get_secure_cookie('counter')
  95 + if user_cookie is not None:
  96 + uid = user_cookie.decode('utf-8')
  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
  102 + return None
90 103
91 104
92 -# ----------------------------------------------------------------------------  
93 -# /rankings  
94 # ---------------------------------------------------------------------------- 105 # ----------------------------------------------------------------------------
95 class RankingsHandler(BaseHandler): 106 class RankingsHandler(BaseHandler):
  107 + '''
  108 + Handles rankings page
  109 + '''
96 @tornado.web.authenticated 110 @tornado.web.authenticated
97 def get(self): 111 def get(self):
  112 + '''
  113 + Renders list of students that have answers in this course.
  114 + '''
98 uid = self.current_user 115 uid = self.current_user
99 current_course = self.learn.get_current_course_id(uid) 116 current_course = self.learn.get_current_course_id(uid)
100 course_id = self.get_query_argument('course', default=current_course) 117 course_id = self.get_query_argument('course', default=current_course)
@@ -110,23 +127,33 @@ class RankingsHandler(BaseHandler): @@ -110,23 +127,33 @@ class RankingsHandler(BaseHandler):
110 127
111 128
112 # ---------------------------------------------------------------------------- 129 # ----------------------------------------------------------------------------
113 -# /auth/login 130 +#
114 # ---------------------------------------------------------------------------- 131 # ----------------------------------------------------------------------------
115 class LoginHandler(BaseHandler): 132 class LoginHandler(BaseHandler):
  133 + '''
  134 + Handles /login
  135 + '''
116 def get(self): 136 def get(self):
  137 + '''
  138 + Renders login page
  139 + '''
117 self.render('login.html', 140 self.render('login.html',
118 appname=APP_NAME, 141 appname=APP_NAME,
119 error='') 142 error='')
120 143
121 async def post(self): 144 async def post(self):
122 - uid = self.get_body_argument('uid').lstrip('l')  
123 - pw = self.get_body_argument('pw') 145 + '''
  146 + Perform authentication and redirects to application if successful
  147 + '''
  148 +
  149 + userid = (self.get_body_argument('uid') or '').lstrip('l')
  150 + passwd = self.get_body_argument('pw')
124 151
125 - login_ok = await self.learn.login(uid, pw) 152 + login_ok = await self.learn.login(userid, passwd)
126 153
127 if login_ok: 154 if login_ok:
128 - counter = str(self.learn.get_login_counter(uid))  
129 - self.set_secure_cookie('user', uid) 155 + counter = str(self.learn.get_login_counter(userid))
  156 + self.set_secure_cookie('aprendizations_user', userid)
130 self.set_secure_cookie('counter', counter) 157 self.set_secure_cookie('counter', counter)
131 self.redirect('/') 158 self.redirect('/')
132 else: 159 else:
@@ -136,11 +163,15 @@ class LoginHandler(BaseHandler): @@ -136,11 +163,15 @@ class LoginHandler(BaseHandler):
136 163
137 164
138 # ---------------------------------------------------------------------------- 165 # ----------------------------------------------------------------------------
139 -# /auth/logout  
140 -# ----------------------------------------------------------------------------  
141 class LogoutHandler(BaseHandler): 166 class LogoutHandler(BaseHandler):
  167 + '''
  168 + Handles /logout
  169 + '''
142 @tornado.web.authenticated 170 @tornado.web.authenticated
143 def get(self): 171 def get(self):
  172 + '''
  173 + clears cookies and removes user session
  174 + '''
144 self.clear_cookie('user') 175 self.clear_cookie('user')
145 self.clear_cookie('counter') 176 self.clear_cookie('counter')
146 self.redirect('/') 177 self.redirect('/')
@@ -151,12 +182,18 @@ class LogoutHandler(BaseHandler): @@ -151,12 +182,18 @@ class LogoutHandler(BaseHandler):
151 182
152 # ---------------------------------------------------------------------------- 183 # ----------------------------------------------------------------------------
153 class ChangePasswordHandler(BaseHandler): 184 class ChangePasswordHandler(BaseHandler):
  185 + '''
  186 + Handles password change
  187 + '''
154 @tornado.web.authenticated 188 @tornado.web.authenticated
155 async def post(self): 189 async def post(self):
156 - uid = self.current_user  
157 - pw = self.get_body_arguments('new_password')[0] 190 + '''
  191 + Tries to perform password change and then replies success/fail status
  192 + '''
  193 + userid = self.current_user
  194 + passwd = self.get_body_arguments('new_password')[0]
158 195
159 - changed_ok = await self.learn.change_password(uid, pw) 196 + changed_ok = await self.learn.change_password(userid, passwd)
160 if changed_ok: 197 if changed_ok:
161 notification = self.render_string( 198 notification = self.render_string(
162 'notification.html', 199 'notification.html',
@@ -174,45 +211,56 @@ class ChangePasswordHandler(BaseHandler): @@ -174,45 +211,56 @@ class ChangePasswordHandler(BaseHandler):
174 211
175 212
176 # ---------------------------------------------------------------------------- 213 # ----------------------------------------------------------------------------
177 -# /  
178 -# redirects to appropriate place  
179 -# ----------------------------------------------------------------------------  
180 class RootHandler(BaseHandler): 214 class RootHandler(BaseHandler):
  215 + '''
  216 + Handles root /
  217 + '''
181 @tornado.web.authenticated 218 @tornado.web.authenticated
182 def get(self): 219 def get(self):
  220 + '''Simply redirects to the main entrypoint'''
183 self.redirect('/courses') 221 self.redirect('/courses')
184 222
185 223
186 # ---------------------------------------------------------------------------- 224 # ----------------------------------------------------------------------------
187 -# /courses  
188 -# Shows a list of available courses  
189 -# ----------------------------------------------------------------------------  
190 class CoursesHandler(BaseHandler): 225 class CoursesHandler(BaseHandler):
  226 + '''
  227 + Handles /courses
  228 + '''
  229 + def set_default_headers(self, *args, **kwargs):
  230 + self.set_header('Cache-Control', 'no-cache')
  231 +
191 @tornado.web.authenticated 232 @tornado.web.authenticated
192 def get(self): 233 def get(self):
  234 + '''Renders list of available courses'''
193 uid = self.current_user 235 uid = self.current_user
194 self.render('courses.html', 236 self.render('courses.html',
195 appname=APP_NAME, 237 appname=APP_NAME,
196 uid=uid, 238 uid=uid,
197 name=self.learn.get_student_name(uid), 239 name=self.learn.get_student_name(uid),
198 courses=self.learn.get_courses(), 240 courses=self.learn.get_courses(),
  241 + # courses_progress=
199 ) 242 )
200 243
201 244
202 -# ----------------------------------------------------------------------------  
203 -# /course/...  
204 -# Start a given course and show list of topics  
205 -# ---------------------------------------------------------------------------- 245 +# ============================================================================
206 class CourseHandler(BaseHandler): 246 class CourseHandler(BaseHandler):
  247 + '''
  248 + Handles a particular course to show the topics table
  249 + '''
  250 +
207 @tornado.web.authenticated 251 @tornado.web.authenticated
208 def get(self, course_id): 252 def get(self, course_id):
  253 + '''
  254 + Handles get /course/...
  255 + Starts a given course and show list of topics
  256 + '''
209 uid = self.current_user 257 uid = self.current_user
210 if course_id == '': 258 if course_id == '':
211 course_id = self.learn.get_current_course_id(uid) 259 course_id = self.learn.get_current_course_id(uid)
212 260
213 try: 261 try:
214 self.learn.start_course(uid, course_id) 262 self.learn.start_course(uid, course_id)
215 - except KeyError: 263 + except LearnException:
216 self.redirect('/courses') 264 self.redirect('/courses')
217 265
218 self.render('maintopics-table.html', 266 self.render('maintopics-table.html',
@@ -225,17 +273,24 @@ class CourseHandler(BaseHandler): @@ -225,17 +273,24 @@ class CourseHandler(BaseHandler):
225 ) 273 )
226 274
227 275
228 -# ----------------------------------------------------------------------------  
229 -# /topic/...  
230 -# Start a given topic  
231 -# ---------------------------------------------------------------------------- 276 +# ============================================================================
232 class TopicHandler(BaseHandler): 277 class TopicHandler(BaseHandler):
  278 + '''
  279 + Handles a topic
  280 + '''
  281 + def set_default_headers(self, *args, **kwargs):
  282 + self.set_header('Cache-Control', 'no-cache')
  283 +
233 @tornado.web.authenticated 284 @tornado.web.authenticated
234 async def get(self, topic): 285 async def get(self, topic):
  286 + '''
  287 + Handles get /topic/...
  288 + Starts a given topic
  289 + '''
235 uid = self.current_user 290 uid = self.current_user
236 291
237 try: 292 try:
238 - await self.learn.start_topic(uid, topic) 293 + await self.learn.start_topic(uid, topic) # FIXME GET should not modify state...
239 except KeyError: 294 except KeyError:
240 self.redirect('/topics') 295 self.redirect('/topics')
241 296
@@ -243,42 +298,43 @@ class TopicHandler(BaseHandler): @@ -243,42 +298,43 @@ class TopicHandler(BaseHandler):
243 appname=APP_NAME, 298 appname=APP_NAME,
244 uid=uid, 299 uid=uid,
245 name=self.learn.get_student_name(uid), 300 name=self.learn.get_student_name(uid),
246 - # course_title=self.learn.get_student_course_title(uid),  
247 course_id=self.learn.get_current_course_id(uid), 301 course_id=self.learn.get_current_course_id(uid),
248 ) 302 )
249 303
250 304
251 -# ----------------------------------------------------------------------------  
252 -# Serves files from the /public subdir of the topics.  
253 -# ---------------------------------------------------------------------------- 305 +# ============================================================================
254 class FileHandler(BaseHandler): 306 class FileHandler(BaseHandler):
  307 + '''
  308 + Serves files from the /public subdir of the topics.
  309 + '''
255 @tornado.web.authenticated 310 @tornado.web.authenticated
256 async def get(self, filename): 311 async def get(self, filename):
  312 + '''
  313 + Serves files from /public subdirectories of a particular topic
  314 + '''
257 uid = self.current_user 315 uid = self.current_user
258 public_dir = self.learn.get_current_public_dir(uid) 316 public_dir = self.learn.get_current_public_dir(uid)
259 - filepath = path.expanduser(path.join(public_dir, filename))  
260 - content_type = mimetypes.guess_type(filename)[0] 317 + filepath = expanduser(join(public_dir, filename))
261 318
262 try: 319 try:
263 - with open(filepath, 'rb') as f:  
264 - data = f.read()  
265 - except FileNotFoundError:  
266 - logger.error(f'File not found: {filepath}')  
267 - except PermissionError:  
268 - logger.error(f'No permission: {filepath}')  
269 - except Exception:  
270 - logger.error(f'Error reading: {filepath}') 320 + with open(filepath, 'rb') as file:
  321 + data = file.read()
  322 + except OSError:
  323 + logger.error('Error reading: %s', filepath)
271 raise 324 raise
272 - else: 325 +
  326 + content_type = mimetypes.guess_type(filename)[0]
  327 + if content_type is not None:
273 self.set_header("Content-Type", content_type) 328 self.set_header("Content-Type", content_type)
274 - self.write(data)  
275 - await self.flush() 329 + self.write(data)
  330 + await self.flush()
276 331
277 332
278 -# ----------------------------------------------------------------------------  
279 -# respond to AJAX to get a JSON question  
280 -# ---------------------------------------------------------------------------- 333 +# ============================================================================
281 class QuestionHandler(BaseHandler): 334 class QuestionHandler(BaseHandler):
  335 + '''
  336 + Responds to AJAX to get a JSON question
  337 + '''
282 templates = { 338 templates = {
283 'checkbox': 'question-checkbox.html', 339 'checkbox': 'question-checkbox.html',
284 'radio': 'question-radio.html', 340 'radio': 'question-radio.html',
@@ -294,27 +350,27 @@ class QuestionHandler(BaseHandler): @@ -294,27 +350,27 @@ class QuestionHandler(BaseHandler):
294 } 350 }
295 351
296 # ------------------------------------------------------------------------ 352 # ------------------------------------------------------------------------
297 - # GET  
298 - # gets question to render. If there are no more questions in the topic  
299 - # shows an animated trophy  
300 - # ------------------------------------------------------------------------  
301 @tornado.web.authenticated 353 @tornado.web.authenticated
302 async def get(self): 354 async def get(self):
  355 + '''
  356 + Gets question to render.
  357 + Shows an animated trophy if there are no more questions in the topic.
  358 + '''
303 logger.debug('[QuestionHandler]') 359 logger.debug('[QuestionHandler]')
304 user = self.current_user 360 user = self.current_user
305 - q = await self.learn.get_question(user) 361 + question = await self.learn.get_question(user)
306 362
307 # show current question 363 # show current question
308 - if q is not None:  
309 - qhtml = self.render_string(self.templates[q['type']],  
310 - question=q, md=md_to_html) 364 + if question is not None:
  365 + qhtml = self.render_string(self.templates[question['type']],
  366 + question=question, md=md_to_html)
311 response = { 367 response = {
312 'method': 'new_question', 368 'method': 'new_question',
313 'params': { 369 'params': {
314 - 'type': q['type'], 370 + 'type': question['type'],
315 'question': to_unicode(qhtml), 371 'question': to_unicode(qhtml),
316 'progress': self.learn.get_student_progress(user), 372 'progress': self.learn.get_student_progress(user),
317 - 'tries': q['tries'], 373 + 'tries': question['tries'],
318 } 374 }
319 } 375 }
320 376
@@ -331,20 +387,20 @@ class QuestionHandler(BaseHandler): @@ -331,20 +387,20 @@ class QuestionHandler(BaseHandler):
331 self.write(response) 387 self.write(response)
332 388
333 # ------------------------------------------------------------------------ 389 # ------------------------------------------------------------------------
334 - # POST  
335 - # corrects answer and returns status: right, wrong, try_again  
336 - # does not move to next question.  
337 - # ------------------------------------------------------------------------  
338 @tornado.web.authenticated 390 @tornado.web.authenticated
339 async def post(self) -> None: 391 async def post(self) -> None:
  392 + '''
  393 + Corrects answer and returns status: right, wrong, try_again
  394 + Does not move to next question.
  395 + '''
340 user = self.current_user 396 user = self.current_user
341 answer = self.get_body_arguments('answer') # list 397 answer = self.get_body_arguments('answer') # list
342 qid = self.get_body_arguments('qid')[0] 398 qid = self.get_body_arguments('qid')[0]
343 - logger.debug(f'[QuestionHandler] answer={answer}') 399 + # logger.debug('[QuestionHandler] answer=%s', answer)
344 400
345 # --- check if browser opened different questions simultaneously 401 # --- check if browser opened different questions simultaneously
346 if qid != self.learn.get_current_question_id(user): 402 if qid != self.learn.get_current_question_id(user):
347 - logger.info(f'User {user} desynchronized questions') 403 + logger.warning('User %s desynchronized questions', user)
348 self.write({ 404 self.write({
349 'method': 'invalid', 405 'method': 'invalid',
350 'params': { 406 'params': {
@@ -370,51 +426,55 @@ class QuestionHandler(BaseHandler): @@ -370,51 +426,55 @@ class QuestionHandler(BaseHandler):
370 ans = answer 426 ans = answer
371 427
372 # --- check answer (nonblocking) and get corrected question and action 428 # --- check answer (nonblocking) and get corrected question and action
373 - q = await self.learn.check_answer(user, ans) 429 + question = await self.learn.check_answer(user, ans)
374 430
375 # --- built response to return 431 # --- built response to return
376 - response = {'method': q['status'], 'params': {}} 432 + response = {'method': question['status'], 'params': {}}
377 433
378 - if q['status'] == 'right': # get next question in the topic  
379 - comments_html = self.render_string(  
380 - 'comments-right.html', comments=q['comments'], md=md_to_html) 434 + if question['status'] == 'right': # get next question in the topic
  435 + comments = self.render_string('comments-right.html',
  436 + comments=question['comments'],
  437 + md=md_to_html)
381 438
382 - solution_html = self.render_string(  
383 - 'solution.html', solution=q['solution'], md=md_to_html) 439 + solution = self.render_string('solution.html',
  440 + solution=question['solution'],
  441 + md=md_to_html)
384 442
385 response['params'] = { 443 response['params'] = {
386 - 'type': q['type'], 444 + 'type': question['type'],
387 'progress': self.learn.get_student_progress(user), 445 'progress': self.learn.get_student_progress(user),
388 - 'comments': to_unicode(comments_html),  
389 - 'solution': to_unicode(solution_html),  
390 - 'tries': q['tries'], 446 + 'comments': to_unicode(comments),
  447 + 'solution': to_unicode(solution),
  448 + 'tries': question['tries'],
391 } 449 }
392 - elif q['status'] == 'try_again':  
393 - comments_html = self.render_string(  
394 - 'comments.html', comments=q['comments'], md=md_to_html) 450 + elif question['status'] == 'try_again':
  451 + comments = self.render_string('comments.html',
  452 + comments=question['comments'],
  453 + md=md_to_html)
395 454
396 response['params'] = { 455 response['params'] = {
397 - 'type': q['type'], 456 + 'type': question['type'],
398 'progress': self.learn.get_student_progress(user), 457 'progress': self.learn.get_student_progress(user),
399 - 'comments': to_unicode(comments_html),  
400 - 'tries': q['tries'], 458 + 'comments': to_unicode(comments),
  459 + 'tries': question['tries'],
401 } 460 }
402 - elif q['status'] == 'wrong': # no more tries  
403 - comments_html = self.render_string(  
404 - 'comments.html', comments=q['comments'], md=md_to_html) 461 + elif question['status'] == 'wrong': # no more tries
  462 + comments = self.render_string('comments.html',
  463 + comments=question['comments'],
  464 + md=md_to_html)
405 465
406 - solution_html = self.render_string(  
407 - 'solution.html', solution=q['solution'], md=md_to_html) 466 + solution = self.render_string(
  467 + 'solution.html', solution=question['solution'], md=md_to_html)
408 468
409 response['params'] = { 469 response['params'] = {
410 - 'type': q['type'], 470 + 'type': question['type'],
411 'progress': self.learn.get_student_progress(user), 471 'progress': self.learn.get_student_progress(user),
412 - 'comments': to_unicode(comments_html),  
413 - 'solution': to_unicode(solution_html),  
414 - 'tries': q['tries'], 472 + 'comments': to_unicode(comments),
  473 + 'solution': to_unicode(solution),
  474 + 'tries': question['tries'],
415 } 475 }
416 else: 476 else:
417 - logger.error(f'Unknown question status: {q["status"]}') 477 + logger.error('Unknown question status: %s', question["status"])
418 478
419 self.write(response) 479 self.write(response)
420 480
@@ -422,29 +482,29 @@ class QuestionHandler(BaseHandler): @@ -422,29 +482,29 @@ class QuestionHandler(BaseHandler):
422 # ---------------------------------------------------------------------------- 482 # ----------------------------------------------------------------------------
423 # Signal handler to catch Ctrl-C and abort server 483 # Signal handler to catch Ctrl-C and abort server
424 # ---------------------------------------------------------------------------- 484 # ----------------------------------------------------------------------------
425 -def signal_handler(signal, frame) -> None:  
426 - r = input(' --> Stop webserver? (yes/no) ').lower()  
427 - if r == 'yes': 485 +def signal_handler(*_) -> None:
  486 + '''
  487 + Catches Ctrl-C and stops webserver
  488 + '''
  489 + reply = input(' --> Stop webserver? (yes/no) ')
  490 + if reply.lower() == 'yes':
428 tornado.ioloop.IOLoop.current().stop() 491 tornado.ioloop.IOLoop.current().stop()
429 - logger.critical('Webserver stopped.') 492 + logging.critical('Webserver stopped.')
430 sys.exit(0) 493 sys.exit(0)
431 - else:  
432 - logger.info('Abort canceled...')  
433 494
434 495
435 # ---------------------------------------------------------------------------- 496 # ----------------------------------------------------------------------------
436 -def run_webserver(app,  
437 - ssl,  
438 - port: int = 8443,  
439 - debug: bool = False) -> None: 497 +def run_webserver(app, ssl, port: int = 8443, debug: bool = False) -> None:
  498 + '''
  499 + Starts and runs webserver until a SIGINT signal (Ctrl-C) is received.
  500 + '''
440 501
441 # --- create web application 502 # --- create web application
442 try: 503 try:
443 webapp = WebApplication(app, debug=debug) 504 webapp = WebApplication(app, debug=debug)
444 except Exception: 505 except Exception:
445 logger.critical('Failed to start web application.') 506 logger.critical('Failed to start web application.')
446 - raise  
447 - # sys.exit(1) 507 + sys.exit(1)
448 else: 508 else:
449 logger.info('Web application started (tornado.web.Application)') 509 logger.info('Web application started (tornado.web.Application)')
450 510
@@ -460,14 +520,12 @@ def run_webserver(app, @@ -460,14 +520,12 @@ def run_webserver(app,
460 try: 520 try:
461 httpserver.listen(port) 521 httpserver.listen(port)
462 except OSError: 522 except OSError:
463 - logger.critical(f'Cannot bind port {port}. Already in use?') 523 + logger.critical('Cannot bind port %d. Already in use?', port)
464 sys.exit(1) 524 sys.exit(1)
465 - else:  
466 - logger.info(f'HTTP server listening on port {port}')  
467 525
468 # --- run webserver 526 # --- run webserver
  527 + logger.info('Webserver listening on %d... (Ctrl-C to stop)', port)
469 signal.signal(signal.SIGINT, signal_handler) 528 signal.signal(signal.SIGINT, signal_handler)
470 - logger.info('Webserver running... (Ctrl-C to stop)')  
471 529
472 try: 530 try:
473 tornado.ioloop.IOLoop.current().start() # running... 531 tornado.ioloop.IOLoop.current().start() # running...
aprendizations/static/css/animate.min.css
@@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
1 -@charset "UTF-8";  
2 -  
3 -/*!  
4 - * animate.css -http://daneden.me/animate  
5 - * Version - 3.6.0  
6 - * Licensed under the MIT license - http://opensource.org/licenses/MIT  
7 - *  
8 - * Copyright (c) 2018 Daniel Eden  
9 - */  
10 -  
11 -.animated{-webkit-animation-duration:1s;animation-duration:1s;-webkit-animation-fill-mode:both;animation-fill-mode:both}.animated.infinite{-webkit-animation-iteration-count:infinite;animation-iteration-count:infinite}@-webkit-keyframes bounce{0%,20%,53%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-30px,0);transform:translate3d(0,-30px,0)}70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-15px,0);transform:translate3d(0,-15px,0)}90%{-webkit-transform:translate3d(0,-4px,0);transform:translate3d(0,-4px,0)}}@keyframes bounce{0%,20%,53%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1);-webkit-transform:translateZ(0);transform:translateZ(0)}40%,43%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-30px,0);transform:translate3d(0,-30px,0)}70%{-webkit-animation-timing-function:cubic-bezier(.755,.05,.855,.06);animation-timing-function:cubic-bezier(.755,.05,.855,.06);-webkit-transform:translate3d(0,-15px,0);transform:translate3d(0,-15px,0)}90%{-webkit-transform:translate3d(0,-4px,0);transform:translate3d(0,-4px,0)}}.bounce{-webkit-animation-name:bounce;animation-name:bounce;-webkit-transform-origin:center bottom;transform-origin:center bottom}@-webkit-keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}@keyframes flash{0%,50%,to{opacity:1}25%,75%{opacity:0}}.flash{-webkit-animation-name:flash;animation-name:flash}@-webkit-keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes pulse{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}50%{-webkit-transform:scale3d(1.05,1.05,1.05);transform:scale3d(1.05,1.05,1.05)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.pulse{-webkit-animation-name:pulse;animation-name:pulse}@-webkit-keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes rubberBand{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}30%{-webkit-transform:scale3d(1.25,.75,1);transform:scale3d(1.25,.75,1)}40%{-webkit-transform:scale3d(.75,1.25,1);transform:scale3d(.75,1.25,1)}50%{-webkit-transform:scale3d(1.15,.85,1);transform:scale3d(1.15,.85,1)}65%{-webkit-transform:scale3d(.95,1.05,1);transform:scale3d(.95,1.05,1)}75%{-webkit-transform:scale3d(1.05,.95,1);transform:scale3d(1.05,.95,1)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.rubberBand{-webkit-animation-name:rubberBand;animation-name:rubberBand}@-webkit-keyframes shake{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}@keyframes shake{0%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}10%,30%,50%,70%,90%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}20%,40%,60%,80%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}}.shake{-webkit-animation-name:shake;animation-name:shake}@-webkit-keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}@keyframes headShake{0%{-webkit-transform:translateX(0);transform:translateX(0)}6.5%{-webkit-transform:translateX(-6px) rotateY(-9deg);transform:translateX(-6px) rotateY(-9deg)}18.5%{-webkit-transform:translateX(5px) rotateY(7deg);transform:translateX(5px) rotateY(7deg)}31.5%{-webkit-transform:translateX(-3px) rotateY(-5deg);transform:translateX(-3px) rotateY(-5deg)}43.5%{-webkit-transform:translateX(2px) rotateY(3deg);transform:translateX(2px) rotateY(3deg)}50%{-webkit-transform:translateX(0);transform:translateX(0)}}.headShake{-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;-webkit-animation-name:headShake;animation-name:headShake}@-webkit-keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}@keyframes swing{20%{-webkit-transform:rotate(15deg);transform:rotate(15deg)}40%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}60%{-webkit-transform:rotate(5deg);transform:rotate(5deg)}80%{-webkit-transform:rotate(-5deg);transform:rotate(-5deg)}to{-webkit-transform:rotate(0deg);transform:rotate(0deg)}}.swing{-webkit-transform-origin:top center;transform-origin:top center;-webkit-animation-name:swing;animation-name:swing}@-webkit-keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes tada{0%{-webkit-transform:scaleX(1);transform:scaleX(1)}10%,20%{-webkit-transform:scale3d(.9,.9,.9) rotate(-3deg);transform:scale3d(.9,.9,.9) rotate(-3deg)}30%,50%,70%,90%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(3deg);transform:scale3d(1.1,1.1,1.1) rotate(3deg)}40%,60%,80%{-webkit-transform:scale3d(1.1,1.1,1.1) rotate(-3deg);transform:scale3d(1.1,1.1,1.1) rotate(-3deg)}to{-webkit-transform:scaleX(1);transform:scaleX(1)}}.tada{-webkit-animation-name:tada;animation-name:tada}@-webkit-keyframes wobble{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes wobble{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}15%{-webkit-transform:translate3d(-25%,0,0) rotate(-5deg);transform:translate3d(-25%,0,0) rotate(-5deg)}30%{-webkit-transform:translate3d(20%,0,0) rotate(3deg);transform:translate3d(20%,0,0) rotate(3deg)}45%{-webkit-transform:translate3d(-15%,0,0) rotate(-3deg);transform:translate3d(-15%,0,0) rotate(-3deg)}60%{-webkit-transform:translate3d(10%,0,0) rotate(2deg);transform:translate3d(10%,0,0) rotate(2deg)}75%{-webkit-transform:translate3d(-5%,0,0) rotate(-1deg);transform:translate3d(-5%,0,0) rotate(-1deg)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.wobble{-webkit-animation-name:wobble;animation-name:wobble}@-webkit-keyframes jello{0%,11.1%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}@keyframes jello{0%,11.1%,to{-webkit-transform:translateZ(0);transform:translateZ(0)}22.2%{-webkit-transform:skewX(-12.5deg) skewY(-12.5deg);transform:skewX(-12.5deg) skewY(-12.5deg)}33.3%{-webkit-transform:skewX(6.25deg) skewY(6.25deg);transform:skewX(6.25deg) skewY(6.25deg)}44.4%{-webkit-transform:skewX(-3.125deg) skewY(-3.125deg);transform:skewX(-3.125deg) skewY(-3.125deg)}55.5%{-webkit-transform:skewX(1.5625deg) skewY(1.5625deg);transform:skewX(1.5625deg) skewY(1.5625deg)}66.6%{-webkit-transform:skewX(-.78125deg) skewY(-.78125deg);transform:skewX(-.78125deg) skewY(-.78125deg)}77.7%{-webkit-transform:skewX(.390625deg) skewY(.390625deg);transform:skewX(.390625deg) skewY(.390625deg)}88.8%{-webkit-transform:skewX(-.1953125deg) skewY(-.1953125deg);transform:skewX(-.1953125deg) skewY(-.1953125deg)}}.jello{-webkit-animation-name:jello;animation-name:jello;-webkit-transform-origin:center;transform-origin:center}@-webkit-keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}@keyframes bounceIn{0%,20%,40%,60%,80%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}20%{-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}40%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}60%{opacity:1;-webkit-transform:scale3d(1.03,1.03,1.03);transform:scale3d(1.03,1.03,1.03)}80%{-webkit-transform:scale3d(.97,.97,.97);transform:scale3d(.97,.97,.97)}to{opacity:1;-webkit-transform:scaleX(1);transform:scaleX(1)}}.bounceIn{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-name:bounceIn;animation-name:bounceIn}@-webkit-keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0);transform:translate3d(0,-3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0);transform:translate3d(0,25px,0)}75%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}90%{-webkit-transform:translate3d(0,5px,0);transform:translate3d(0,5px,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInDown{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,-3000px,0);transform:translate3d(0,-3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,25px,0);transform:translate3d(0,25px,0)}75%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}90%{-webkit-transform:translate3d(0,5px,0);transform:translate3d(0,5px,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.bounceInDown{-webkit-animation-name:bounceInDown;animation-name:bounceInDown}@-webkit-keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0);transform:translate3d(-3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0);transform:translate3d(25px,0,0)}75%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}90%{-webkit-transform:translate3d(5px,0,0);transform:translate3d(5px,0,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInLeft{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(-3000px,0,0);transform:translate3d(-3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(25px,0,0);transform:translate3d(25px,0,0)}75%{-webkit-transform:translate3d(-10px,0,0);transform:translate3d(-10px,0,0)}90%{-webkit-transform:translate3d(5px,0,0);transform:translate3d(5px,0,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.bounceInLeft{-webkit-animation-name:bounceInLeft;animation-name:bounceInLeft}@-webkit-keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0);transform:translate3d(3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0);transform:translate3d(-25px,0,0)}75%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}90%{-webkit-transform:translate3d(-5px,0,0);transform:translate3d(-5px,0,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInRight{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(3000px,0,0);transform:translate3d(3000px,0,0)}60%{opacity:1;-webkit-transform:translate3d(-25px,0,0);transform:translate3d(-25px,0,0)}75%{-webkit-transform:translate3d(10px,0,0);transform:translate3d(10px,0,0)}90%{-webkit-transform:translate3d(-5px,0,0);transform:translate3d(-5px,0,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.bounceInRight{-webkit-animation-name:bounceInRight;animation-name:bounceInRight}@-webkit-keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0);transform:translate3d(0,3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}75%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}90%{-webkit-transform:translate3d(0,-5px,0);transform:translate3d(0,-5px,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes bounceInUp{0%,60%,75%,90%,to{-webkit-animation-timing-function:cubic-bezier(.215,.61,.355,1);animation-timing-function:cubic-bezier(.215,.61,.355,1)}0%{opacity:0;-webkit-transform:translate3d(0,3000px,0);transform:translate3d(0,3000px,0)}60%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}75%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}90%{-webkit-transform:translate3d(0,-5px,0);transform:translate3d(0,-5px,0)}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.bounceInUp{-webkit-animation-name:bounceInUp;animation-name:bounceInUp}@-webkit-keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}@keyframes bounceOut{20%{-webkit-transform:scale3d(.9,.9,.9);transform:scale3d(.9,.9,.9)}50%,55%{opacity:1;-webkit-transform:scale3d(1.1,1.1,1.1);transform:scale3d(1.1,1.1,1.1)}to{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}}.bounceOut{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-name:bounceOut;animation-name:bounceOut}@-webkit-keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}@keyframes bounceOutDown{20%{-webkit-transform:translate3d(0,10px,0);transform:translate3d(0,10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,-20px,0);transform:translate3d(0,-20px,0)}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}.bounceOutDown{-webkit-animation-name:bounceOutDown;animation-name:bounceOutDown}@-webkit-keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0);transform:translate3d(20px,0,0)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}@keyframes bounceOutLeft{20%{opacity:1;-webkit-transform:translate3d(20px,0,0);transform:translate3d(20px,0,0)}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}.bounceOutLeft{-webkit-animation-name:bounceOutLeft;animation-name:bounceOutLeft}@-webkit-keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0);transform:translate3d(-20px,0,0)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}@keyframes bounceOutRight{20%{opacity:1;-webkit-transform:translate3d(-20px,0,0);transform:translate3d(-20px,0,0)}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}.bounceOutRight{-webkit-animation-name:bounceOutRight;animation-name:bounceOutRight}@-webkit-keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0);transform:translate3d(0,20px,0)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}@keyframes bounceOutUp{20%{-webkit-transform:translate3d(0,-10px,0);transform:translate3d(0,-10px,0)}40%,45%{opacity:1;-webkit-transform:translate3d(0,20px,0);transform:translate3d(0,20px,0)}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}.bounceOutUp{-webkit-animation-name:bounceOutUp;animation-name:bounceOutUp}@-webkit-keyframes fadeIn{0%{opacity:0}to{opacity:1}}@keyframes fadeIn{0%{opacity:0}to{opacity:1}}.fadeIn{-webkit-animation-name:fadeIn;animation-name:fadeIn}@-webkit-keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInDown{0%{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.fadeInDown{-webkit-animation-name:fadeInDown;animation-name:fadeInDown}@-webkit-keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInDownBig{0%{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.fadeInDownBig{-webkit-animation-name:fadeInDownBig;animation-name:fadeInDownBig}@-webkit-keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInLeft{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.fadeInLeft{-webkit-animation-name:fadeInLeft;animation-name:fadeInLeft}@-webkit-keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInLeftBig{0%{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.fadeInLeftBig{-webkit-animation-name:fadeInLeftBig;animation-name:fadeInLeftBig}@-webkit-keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInRight{0%{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.fadeInRight{-webkit-animation-name:fadeInRight;animation-name:fadeInRight}@-webkit-keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInRightBig{0%{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.fadeInRightBig{-webkit-animation-name:fadeInRightBig;animation-name:fadeInRightBig}@-webkit-keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInUp{0%{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.fadeInUp{-webkit-animation-name:fadeInUp;animation-name:fadeInUp}@-webkit-keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes fadeInUpBig{0%{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.fadeInUpBig{-webkit-animation-name:fadeInUpBig;animation-name:fadeInUpBig}@-webkit-keyframes fadeOut{0%{opacity:1}to{opacity:0}}@keyframes fadeOut{0%{opacity:1}to{opacity:0}}.fadeOut{-webkit-animation-name:fadeOut;animation-name:fadeOut}@-webkit-keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes fadeOutDown{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.fadeOutDown{-webkit-animation-name:fadeOutDown;animation-name:fadeOutDown}@-webkit-keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}@keyframes fadeOutDownBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,2000px,0);transform:translate3d(0,2000px,0)}}.fadeOutDownBig{-webkit-animation-name:fadeOutDownBig;animation-name:fadeOutDownBig}@-webkit-keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes fadeOutLeft{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.fadeOutLeft{-webkit-animation-name:fadeOutLeft;animation-name:fadeOutLeft}@-webkit-keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}@keyframes fadeOutLeftBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(-2000px,0,0);transform:translate3d(-2000px,0,0)}}.fadeOutLeftBig{-webkit-animation-name:fadeOutLeftBig;animation-name:fadeOutLeftBig}@-webkit-keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes fadeOutRight{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.fadeOutRight{-webkit-animation-name:fadeOutRight;animation-name:fadeOutRight}@-webkit-keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}@keyframes fadeOutRightBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(2000px,0,0);transform:translate3d(2000px,0,0)}}.fadeOutRightBig{-webkit-animation-name:fadeOutRightBig;animation-name:fadeOutRightBig}@-webkit-keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes fadeOutUp{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.fadeOutUp{-webkit-animation-name:fadeOutUp;animation-name:fadeOutUp}@-webkit-keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}@keyframes fadeOutUpBig{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(0,-2000px,0);transform:translate3d(0,-2000px,0)}}.fadeOutUpBig{-webkit-animation-name:fadeOutUpBig;animation-name:fadeOutUpBig}@-webkit-keyframes flip{0%{-webkit-transform:perspective(400px) rotateY(-1turn);transform:perspective(400px) rotateY(-1turn);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-190deg);transform:perspective(400px) translateZ(150px) rotateY(-190deg);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}50%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-170deg);transform:perspective(400px) translateZ(150px) rotateY(-170deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95);transform:perspective(400px) scale3d(.95,.95,.95);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}to{-webkit-transform:perspective(400px);transform:perspective(400px);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}@keyframes flip{0%{-webkit-transform:perspective(400px) rotateY(-1turn);transform:perspective(400px) rotateY(-1turn);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}40%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-190deg);transform:perspective(400px) translateZ(150px) rotateY(-190deg);-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}50%{-webkit-transform:perspective(400px) translateZ(150px) rotateY(-170deg);transform:perspective(400px) translateZ(150px) rotateY(-170deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}80%{-webkit-transform:perspective(400px) scale3d(.95,.95,.95);transform:perspective(400px) scale3d(.95,.95,.95);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}to{-webkit-transform:perspective(400px);transform:perspective(400px);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}}.animated.flip{-webkit-backface-visibility:visible;backface-visibility:visible;-webkit-animation-name:flip;animation-name:flip}@-webkit-keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInX{0%{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateX(10deg);transform:perspective(400px) rotateX(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateX(-5deg);transform:perspective(400px) rotateX(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.flipInX{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInX;animation-name:flipInX}@-webkit-keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}@keyframes flipInY{0%{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in;opacity:0}40%{-webkit-transform:perspective(400px) rotateY(-20deg);transform:perspective(400px) rotateY(-20deg);-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}60%{-webkit-transform:perspective(400px) rotateY(10deg);transform:perspective(400px) rotateY(10deg);opacity:1}80%{-webkit-transform:perspective(400px) rotateY(-5deg);transform:perspective(400px) rotateY(-5deg)}to{-webkit-transform:perspective(400px);transform:perspective(400px)}}.flipInY{-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipInY;animation-name:flipInY}@-webkit-keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}@keyframes flipOutX{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateX(-20deg);transform:perspective(400px) rotateX(-20deg);opacity:1}to{-webkit-transform:perspective(400px) rotateX(90deg);transform:perspective(400px) rotateX(90deg);opacity:0}}.flipOutX{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-animation-name:flipOutX;animation-name:flipOutX;-webkit-backface-visibility:visible!important;backface-visibility:visible!important}@-webkit-keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}@keyframes flipOutY{0%{-webkit-transform:perspective(400px);transform:perspective(400px)}30%{-webkit-transform:perspective(400px) rotateY(-15deg);transform:perspective(400px) rotateY(-15deg);opacity:1}to{-webkit-transform:perspective(400px) rotateY(90deg);transform:perspective(400px) rotateY(90deg);opacity:0}}.flipOutY{-webkit-animation-duration:.75s;animation-duration:.75s;-webkit-backface-visibility:visible!important;backface-visibility:visible!important;-webkit-animation-name:flipOutY;animation-name:flipOutY}@-webkit-keyframes lightSpeedIn{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg);opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg);opacity:1}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes lightSpeedIn{0%{-webkit-transform:translate3d(100%,0,0) skewX(-30deg);transform:translate3d(100%,0,0) skewX(-30deg);opacity:0}60%{-webkit-transform:skewX(20deg);transform:skewX(20deg);opacity:1}80%{-webkit-transform:skewX(-5deg);transform:skewX(-5deg);opacity:1}to{-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.lightSpeedIn{-webkit-animation-name:lightSpeedIn;animation-name:lightSpeedIn;-webkit-animation-timing-function:ease-out;animation-timing-function:ease-out}@-webkit-keyframes lightSpeedOut{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}@keyframes lightSpeedOut{0%{opacity:1}to{-webkit-transform:translate3d(100%,0,0) skewX(30deg);transform:translate3d(100%,0,0) skewX(30deg);opacity:0}}.lightSpeedOut{-webkit-animation-name:lightSpeedOut;animation-name:lightSpeedOut;-webkit-animation-timing-function:ease-in;animation-timing-function:ease-in}@-webkit-keyframes rotateIn{0%{-webkit-transform-origin:center;transform-origin:center;-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}to{-webkit-transform-origin:center;transform-origin:center;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateIn{0%{-webkit-transform-origin:center;transform-origin:center;-webkit-transform:rotate(-200deg);transform:rotate(-200deg);opacity:0}to{-webkit-transform-origin:center;transform-origin:center;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.rotateIn{-webkit-animation-name:rotateIn;animation-name:rotateIn}@-webkit-keyframes rotateInDownLeft{0%{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}to{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInDownLeft{0%{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}to{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.rotateInDownLeft{-webkit-animation-name:rotateInDownLeft;animation-name:rotateInDownLeft}@-webkit-keyframes rotateInDownRight{0%{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInDownRight{0%{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.rotateInDownRight{-webkit-animation-name:rotateInDownRight;animation-name:rotateInDownRight}@-webkit-keyframes rotateInUpLeft{0%{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInUpLeft{0%{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}to{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.rotateInUpLeft{-webkit-animation-name:rotateInUpLeft;animation-name:rotateInUpLeft}@-webkit-keyframes rotateInUpRight{0%{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}to{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}@keyframes rotateInUpRight{0%{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:rotate(-90deg);transform:rotate(-90deg);opacity:0}to{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:translateZ(0);transform:translateZ(0);opacity:1}}.rotateInUpRight{-webkit-animation-name:rotateInUpRight;animation-name:rotateInUpRight}@-webkit-keyframes rotateOut{0%{-webkit-transform-origin:center;transform-origin:center;opacity:1}to{-webkit-transform-origin:center;transform-origin:center;-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}@keyframes rotateOut{0%{-webkit-transform-origin:center;transform-origin:center;opacity:1}to{-webkit-transform-origin:center;transform-origin:center;-webkit-transform:rotate(200deg);transform:rotate(200deg);opacity:0}}.rotateOut{-webkit-animation-name:rotateOut;animation-name:rotateOut}@-webkit-keyframes rotateOutDownLeft{0%{-webkit-transform-origin:left bottom;transform-origin:left bottom;opacity:1}to{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}@keyframes rotateOutDownLeft{0%{-webkit-transform-origin:left bottom;transform-origin:left bottom;opacity:1}to{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:rotate(45deg);transform:rotate(45deg);opacity:0}}.rotateOutDownLeft{-webkit-animation-name:rotateOutDownLeft;animation-name:rotateOutDownLeft}@-webkit-keyframes rotateOutDownRight{0%{-webkit-transform-origin:right bottom;transform-origin:right bottom;opacity:1}to{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutDownRight{0%{-webkit-transform-origin:right bottom;transform-origin:right bottom;opacity:1}to{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.rotateOutDownRight{-webkit-animation-name:rotateOutDownRight;animation-name:rotateOutDownRight}@-webkit-keyframes rotateOutUpLeft{0%{-webkit-transform-origin:left bottom;transform-origin:left bottom;opacity:1}to{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}@keyframes rotateOutUpLeft{0%{-webkit-transform-origin:left bottom;transform-origin:left bottom;opacity:1}to{-webkit-transform-origin:left bottom;transform-origin:left bottom;-webkit-transform:rotate(-45deg);transform:rotate(-45deg);opacity:0}}.rotateOutUpLeft{-webkit-animation-name:rotateOutUpLeft;animation-name:rotateOutUpLeft}@-webkit-keyframes rotateOutUpRight{0%{-webkit-transform-origin:right bottom;transform-origin:right bottom;opacity:1}to{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}@keyframes rotateOutUpRight{0%{-webkit-transform-origin:right bottom;transform-origin:right bottom;opacity:1}to{-webkit-transform-origin:right bottom;transform-origin:right bottom;-webkit-transform:rotate(90deg);transform:rotate(90deg);opacity:0}}.rotateOutUpRight{-webkit-animation-name:rotateOutUpRight;animation-name:rotateOutUpRight}@-webkit-keyframes hinge{0%{-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}@keyframes hinge{0%{-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}20%,60%{-webkit-transform:rotate(80deg);transform:rotate(80deg);-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out}40%,80%{-webkit-transform:rotate(60deg);transform:rotate(60deg);-webkit-transform-origin:top left;transform-origin:top left;-webkit-animation-timing-function:ease-in-out;animation-timing-function:ease-in-out;opacity:1}to{-webkit-transform:translate3d(0,700px,0);transform:translate3d(0,700px,0);opacity:0}}.hinge{-webkit-animation-duration:2s;animation-duration:2s;-webkit-animation-name:hinge;animation-name:hinge}@-webkit-keyframes jackInTheBox{0%{opacity:0;-webkit-transform:scale(.1) rotate(30deg);transform:scale(.1) rotate(30deg);-webkit-transform-origin:center bottom;transform-origin:center bottom}50%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}70%{-webkit-transform:rotate(3deg);transform:rotate(3deg)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}@keyframes jackInTheBox{0%{opacity:0;-webkit-transform:scale(.1) rotate(30deg);transform:scale(.1) rotate(30deg);-webkit-transform-origin:center bottom;transform-origin:center bottom}50%{-webkit-transform:rotate(-10deg);transform:rotate(-10deg)}70%{-webkit-transform:rotate(3deg);transform:rotate(3deg)}to{opacity:1;-webkit-transform:scale(1);transform:scale(1)}}.jackInTheBox{-webkit-animation-name:jackInTheBox;animation-name:jackInTheBox}@-webkit-keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes rollIn{0%{opacity:0;-webkit-transform:translate3d(-100%,0,0) rotate(-120deg);transform:translate3d(-100%,0,0) rotate(-120deg)}to{opacity:1;-webkit-transform:translateZ(0);transform:translateZ(0)}}.rollIn{-webkit-animation-name:rollIn;animation-name:rollIn}@-webkit-keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}@keyframes rollOut{0%{opacity:1}to{opacity:0;-webkit-transform:translate3d(100%,0,0) rotate(120deg);transform:translate3d(100%,0,0) rotate(120deg)}}.rollOut{-webkit-animation-name:rollOut;animation-name:rollOut}@-webkit-keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}@keyframes zoomIn{0%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}50%{opacity:1}}.zoomIn{-webkit-animation-name:zoomIn;animation-name:zoomIn}@-webkit-keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInDown{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInDown{-webkit-animation-name:zoomInDown;animation-name:zoomInDown}@-webkit-keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInLeft{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(-1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(10px,0,0);transform:scale3d(.475,.475,.475) translate3d(10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInLeft{-webkit-animation-name:zoomInLeft;animation-name:zoomInLeft}@-webkit-keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInRight{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);transform:scale3d(.1,.1,.1) translate3d(1000px,0,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);transform:scale3d(.475,.475,.475) translate3d(-10px,0,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInRight{-webkit-animation-name:zoomInRight;animation-name:zoomInRight}@-webkit-keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomInUp{0%{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);transform:scale3d(.1,.1,.1) translate3d(0,1000px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}60%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomInUp{-webkit-animation-name:zoomInUp;animation-name:zoomInUp}@-webkit-keyframes zoomOut{0%{opacity:1}50%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}to{opacity:0}}@keyframes zoomOut{0%{opacity:1}50%{opacity:0;-webkit-transform:scale3d(.3,.3,.3);transform:scale3d(.3,.3,.3)}to{opacity:0}}.zoomOut{-webkit-animation-name:zoomOut;animation-name:zoomOut}@-webkit-keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutDown{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);transform:scale3d(.475,.475,.475) translate3d(0,-60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomOutDown{-webkit-animation-name:zoomOutDown;animation-name:zoomOutDown}@-webkit-keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0);-webkit-transform-origin:left center;transform-origin:left center}}@keyframes zoomOutLeft{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(42px,0,0);transform:scale3d(.475,.475,.475) translate3d(42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(-2000px,0,0);transform:scale(.1) translate3d(-2000px,0,0);-webkit-transform-origin:left center;transform-origin:left center}}.zoomOutLeft{-webkit-animation-name:zoomOutLeft;animation-name:zoomOutLeft}@-webkit-keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0);-webkit-transform-origin:right center;transform-origin:right center}}@keyframes zoomOutRight{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(-42px,0,0);transform:scale3d(.475,.475,.475) translate3d(-42px,0,0)}to{opacity:0;-webkit-transform:scale(.1) translate3d(2000px,0,0);transform:scale(.1) translate3d(2000px,0,0);-webkit-transform-origin:right center;transform-origin:right center}}.zoomOutRight{-webkit-animation-name:zoomOutRight;animation-name:zoomOutRight}@-webkit-keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}@keyframes zoomOutUp{40%{opacity:1;-webkit-transform:scale3d(.475,.475,.475) translate3d(0,60px,0);transform:scale3d(.475,.475,.475) translate3d(0,60px,0);-webkit-animation-timing-function:cubic-bezier(.55,.055,.675,.19);animation-timing-function:cubic-bezier(.55,.055,.675,.19)}to{opacity:0;-webkit-transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);transform:scale3d(.1,.1,.1) translate3d(0,-2000px,0);-webkit-transform-origin:center bottom;transform-origin:center bottom;-webkit-animation-timing-function:cubic-bezier(.175,.885,.32,1);animation-timing-function:cubic-bezier(.175,.885,.32,1)}}.zoomOutUp{-webkit-animation-name:zoomOutUp;animation-name:zoomOutUp}@-webkit-keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInDown{0%{-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInDown{-webkit-animation-name:slideInDown;animation-name:slideInDown}@-webkit-keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInLeft{0%{-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInLeft{-webkit-animation-name:slideInLeft;animation-name:slideInLeft}@-webkit-keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInRight{0%{-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInRight{-webkit-animation-name:slideInRight;animation-name:slideInRight}@-webkit-keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}@keyframes slideInUp{0%{-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0);visibility:visible}to{-webkit-transform:translateZ(0);transform:translateZ(0)}}.slideInUp{-webkit-animation-name:slideInUp;animation-name:slideInUp}@-webkit-keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}@keyframes slideOutDown{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,100%,0);transform:translate3d(0,100%,0)}}.slideOutDown{-webkit-animation-name:slideOutDown;animation-name:slideOutDown}@-webkit-keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}@keyframes slideOutLeft{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(-100%,0,0);transform:translate3d(-100%,0,0)}}.slideOutLeft{-webkit-animation-name:slideOutLeft;animation-name:slideOutLeft}@-webkit-keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}@keyframes slideOutRight{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(100%,0,0);transform:translate3d(100%,0,0)}}.slideOutRight{-webkit-animation-name:slideOutRight;animation-name:slideOutRight}@-webkit-keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}@keyframes slideOutUp{0%{-webkit-transform:translateZ(0);transform:translateZ(0)}to{visibility:hidden;-webkit-transform:translate3d(0,-100%,0);transform:translate3d(0,-100%,0)}}.slideOutUp{-webkit-animation-name:slideOutUp;animation-name:slideOutUp}  
12 \ No newline at end of file 0 \ No newline at end of file
aprendizations/static/css/signin.css 0 → 100644
@@ -0,0 +1,39 @@ @@ -0,0 +1,39 @@
  1 +html,
  2 +body {
  3 + height: 100%;
  4 +}
  5 +
  6 +body {
  7 + display: flex;
  8 + align-items: center;
  9 + padding-top: 40px;
  10 + padding-bottom: 40px;
  11 + background-color: #f5f5f5;
  12 +}
  13 +
  14 +.form-signin {
  15 + width: 100%;
  16 + max-width: 330px;
  17 + padding: 15px;
  18 + margin: auto;
  19 +}
  20 +
  21 +.form-signin .checkbox {
  22 + font-weight: 400;
  23 +}
  24 +
  25 +.form-signin .form-floating:focus-within {
  26 + z-index: 2;
  27 +}
  28 +
  29 +.form-signin input[type="email"] {
  30 + margin-bottom: -1px;
  31 + border-bottom-right-radius: 0;
  32 + border-bottom-left-radius: 0;
  33 +}
  34 +
  35 +.form-signin input[type="password"] {
  36 + margin-bottom: 10px;
  37 + border-top-left-radius: 0;
  38 + border-top-right-radius: 0;
  39 +}
aprendizations/static/css/topic.css
1 -.progress {  
2 - /*position: fixed;*/  
3 - top: 0;  
4 - height: 70px;  
5 - border-radius: 0px;  
6 -}  
7 body { 1 body {
8 - margin: 0;  
9 - padding-top: 0px;  
10 margin-bottom: 120px; /* Margin bottom by footer height */ 2 margin-bottom: 120px; /* Margin bottom by footer height */
11 } 3 }
12 4
@@ -19,10 +11,6 @@ body { @@ -19,10 +11,6 @@ body {
19 /*background-color: #f5f5f5;*/ 11 /*background-color: #f5f5f5;*/
20 } 12 }
21 13
22 -html {  
23 - position: relative;  
24 - min-height: 100%;  
25 -}  
26 .CodeMirror { 14 .CodeMirror {
27 border: 1px solid #eee; 15 border: 1px solid #eee;
28 height: auto; 16 height: auto;
aprendizations/static/js/topic.js
1 $.fn.extend({ 1 $.fn.extend({
2 animateCSS: function (animation, run_on_end) { 2 animateCSS: function (animation, run_on_end) {
3 var animationEnd = 'webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend'; 3 var animationEnd = 'webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend';
4 - this.addClass('animated ' + animation).one(animationEnd, function() {  
5 - $(this).removeClass('animated ' + animation); 4 + this.addClass('animate__animated ' + animation).one(animationEnd, function() {
  5 + $(this).removeClass('animate__animated ' + animation);
6 if (run_on_end !== undefined) { 6 if (run_on_end !== undefined) {
7 run_on_end(); 7 run_on_end();
8 } 8 }
@@ -46,7 +46,7 @@ function updateQuestion(response) { @@ -46,7 +46,7 @@ function updateQuestion(response) {
46 break; 46 break;
47 case "finished_topic": 47 case "finished_topic":
48 $('#submit, #comments, #solution').remove(); 48 $('#submit, #comments, #solution').remove();
49 - $("#content").html(params["question"]).animateCSS('tada'); 49 + $("#content").html(params["question"]).animateCSS('animate__tada');
50 $('#topic_progress').css('width', '100%').attr('aria-valuenow', 100); 50 $('#topic_progress').css('width', '100%').attr('aria-valuenow', 100);
51 setTimeout(function(){window.location.replace('/course/');}, 2000); 51 setTimeout(function(){window.location.replace('/course/');}, 2000);
52 break; 52 break;
@@ -57,10 +57,10 @@ function new_question(type, question, tries, progress) { @@ -57,10 +57,10 @@ function new_question(type, question, tries, progress) {
57 window.scrollTo(0, 0); 57 window.scrollTo(0, 0);
58 58
59 $("#submit").hide(); 59 $("#submit").hide();
60 - $("#question_div").animateCSS('fadeOut', function() { 60 + $("#question_div").animateCSS('animate__fadeOut', function() {
61 $("#question_div").html(question); 61 $("#question_div").html(question);
62 MathJax.typeset(); 62 MathJax.typeset();
63 - $("#question_div").animateCSS('fadeIn', function() { 63 + $("#question_div").animateCSS('animate__fadeIn', function() {
64 showTriesLeft(tries); 64 showTriesLeft(tries);
65 $("#submit").removeClass("disabled").show(); 65 $("#submit").removeClass("disabled").show();
66 66
@@ -120,7 +120,7 @@ function getFeedback(response) { @@ -120,7 +120,7 @@ function getFeedback(response) {
120 $('#comments').html(params['comments']).show(); 120 $('#comments').html(params['comments']).show();
121 $('#solution_right').html(params['solution']); 121 $('#solution_right').html(params['solution']);
122 MathJax.typeset(); 122 MathJax.typeset();
123 - $('#right').show().animateCSS('zoomIn', function(){ 123 + $('#right').show().animateCSS('animate__zoomIn', function(){
124 $("#submit").html("Continuar").removeClass("disabled").off().click(getQuestion); 124 $("#submit").html("Continuar").removeClass("disabled").off().click(getQuestion);
125 }); 125 });
126 break; 126 break;
@@ -129,7 +129,10 @@ function getFeedback(response) { @@ -129,7 +129,10 @@ function getFeedback(response) {
129 $('#comments').html(params['comments']).show(); 129 $('#comments').html(params['comments']).show();
130 MathJax.typeset(); 130 MathJax.typeset();
131 $('#topic_progress').css('width', (100*params["progress"])+'%').attr('aria-valuenow', 100*params["progress"]); 131 $('#topic_progress').css('width', (100*params["progress"])+'%').attr('aria-valuenow', 100*params["progress"]);
132 - $('#question_div').animateCSS('shake', function() { 132 +
  133 +
  134 +
  135 + $('#question_div').animateCSS('animate__shakeX', function() {
133 showTriesLeft(params["tries"]); 136 showTriesLeft(params["tries"]);
134 $("fieldset").prop("disabled", false); 137 $("fieldset").prop("disabled", false);
135 $("#submit").html("Responder").removeClass("disabled"); 138 $("#submit").html("Responder").removeClass("disabled");
@@ -142,9 +145,9 @@ function getFeedback(response) { @@ -142,9 +145,9 @@ function getFeedback(response) {
142 $('#comments').html(params['comments']).show(); 145 $('#comments').html(params['comments']).show();
143 $('#solution_wrong').html(params['solution']); 146 $('#solution_wrong').html(params['solution']);
144 MathJax.typeset(); 147 MathJax.typeset();
145 - $('#question_div').animateCSS('shake', function() { 148 + $('#question_div').animateCSS('animate__shakeX', function() {
146 showTriesLeft(params["tries"]); 149 showTriesLeft(params["tries"]);
147 - $('#wrong').show().animateCSS('zoomIn', function() { 150 + $('#wrong').show().animateCSS('animate__zoomIn', function() {
148 $("#submit").html("Continuar").removeClass("disabled").off().click(getQuestion); 151 $("#submit").html("Continuar").removeClass("disabled").off().click(getQuestion);
149 }); 152 });
150 }); 153 });
aprendizations/static/mdbootstrap
@@ -1 +0,0 @@ @@ -1 +0,0 @@
1 -../../node_modules/mdbootstrap/  
2 \ No newline at end of file 0 \ No newline at end of file
aprendizations/student.py
1 1
  2 +'''
  3 +Implementation of the StudentState class.
  4 +Each object of this class will contain the state of a student while logged in.
  5 +Manages things like current course, topic, question, etc, and defines the
  6 +logic of the application in what it applies to a single student.
  7 +'''
  8 +
2 # python standard library 9 # python standard library
3 from datetime import datetime 10 from datetime import datetime
4 import logging 11 import logging
5 import random 12 import random
6 -from typing import List, Optional, Tuple 13 +from typing import List, Optional
7 14
8 # third party libraries 15 # third party libraries
9 import networkx as nx 16 import networkx as nx
10 17
11 # this project 18 # this project
12 -from .questions import Question 19 +from aprendizations.questions import Question
13 20
14 21
15 # setup logger for this module 22 # setup logger for this module
16 logger = logging.getLogger(__name__) 23 logger = logging.getLogger(__name__)
17 24
18 25
19 -# ----------------------------------------------------------------------------  
20 -# kowledge state of a student:  
21 -# uid - string with userid, e.g. '12345'  
22 -# state - dict of unlocked topics and their levels  
23 -# {'topic1': {'level': 0.5, 'date': datetime}, ...}  
24 -# topic_sequence - recommended topic sequence ['topic1', 'topic2', ...]  
25 -# questions - [Question, ...] for the current topic  
26 -# current_course - string or None  
27 -# current_topic - string or None  
28 -# current_question - Question or None  
29 -#  
30 -#  
31 -# also has access to shared data between students:  
32 -# courses - dictionary {course: [topic1, ...]}  
33 -# deps - dependency graph as a networkx digraph  
34 -# factory - dictionary {ref: QFactory}  
35 -# ----------------------------------------------------------------------------  
36 -class StudentState(object):  
37 - # ======================================================================= 26 +# ============================================================================
  27 +class StudentState():
  28 + '''
  29 + kowledge state of a student:
  30 + uid - string with userid, e.g. '12345'
  31 + state - dict of unlocked topics and their levels
  32 + {'topic1': {'level': 0.5, 'date': datetime}, ...}
  33 + topic_sequence - recommended topic sequence ['topic1', 'topic2', ...]
  34 + questions - [Question, ...] for the current topic
  35 + current_course - string or None
  36 + current_topic - string or None
  37 + current_question - Question or None
  38 + also has access to shared data between students:
  39 + courses - dictionary {course: [topic1, ...]}
  40 + deps - dependency graph as a networkx digraph
  41 + factory - dictionary {ref: QFactory}
  42 + '''
  43 +
  44 + # ========================================================================
38 # methods that update state 45 # methods that update state
39 - # ======================================================================= 46 + # ========================================================================
40 def __init__(self, uid, state, courses, deps, factory) -> None: 47 def __init__(self, uid, state, courses, deps, factory) -> None:
41 # shared application data between all students 48 # shared application data between all students
42 self.deps = deps # dependency graph 49 self.deps = deps # dependency graph
@@ -54,6 +61,10 @@ class StudentState(object): @@ -54,6 +61,10 @@ class StudentState(object):
54 61
55 # ------------------------------------------------------------------------ 62 # ------------------------------------------------------------------------
56 def start_course(self, course: Optional[str]) -> None: 63 def start_course(self, course: Optional[str]) -> None:
  64 + '''
  65 + Tries to start a course.
  66 + Finds the recommended sequence of topics for the student.
  67 + '''
57 if course is None: 68 if course is None:
58 logger.debug('no active course') 69 logger.debug('no active course')
59 self.current_course: Optional[str] = None 70 self.current_course: Optional[str] = None
@@ -63,125 +74,136 @@ class StudentState(object): @@ -63,125 +74,136 @@ class StudentState(object):
63 try: 74 try:
64 topics = self.courses[course]['goals'] 75 topics = self.courses[course]['goals']
65 except KeyError: 76 except KeyError:
66 - logger.debug(f'course "{course}" does not exist') 77 + logger.debug('course "%s" does not exist', course)
67 raise 78 raise
68 - logger.debug(f'starting course "{course}"') 79 + logger.debug('starting course "%s"', course)
69 self.current_course = course 80 self.current_course = course
70 - self.topic_sequence = self.recommend_sequence(topics) 81 + self.topic_sequence = self._recommend_sequence(topics)
71 82
72 # ------------------------------------------------------------------------ 83 # ------------------------------------------------------------------------
73 - # Start a new topic.  
74 - # questions: list of generated questions to do in the given topic  
75 - # current_question: the current question to be presented  
76 - # ------------------------------------------------------------------------  
77 - async def start_topic(self, topic: str) -> None:  
78 - logger.debug(f'start topic "{topic}"') 84 + async def start_topic(self, topic_ref: str) -> None:
  85 + '''
  86 + Start a new topic.
  87 + questions: list of generated questions to do in the given topic
  88 + current_question: the current question to be presented
  89 + '''
  90 +
  91 + logger.debug('start topic "%s"', topic_ref)
79 92
80 # avoid regenerating questions in the middle of the current topic 93 # avoid regenerating questions in the middle of the current topic
81 - if self.current_topic == topic and self.uid != '0': 94 + if self.current_topic == topic_ref and self.uid != '0':
82 logger.info('Restarting current topic is not allowed.') 95 logger.info('Restarting current topic is not allowed.')
83 return 96 return
84 97
85 # do not allow locked topics 98 # do not allow locked topics
86 - if self.is_locked(topic) and self.uid != '0':  
87 - logger.debug(f'is locked "{topic}"') 99 + if self.is_locked(topic_ref) and self.uid != '0':
  100 + logger.debug('is locked "%s"', topic_ref)
88 return 101 return
89 102
90 self.previous_topic: Optional[str] = None 103 self.previous_topic: Optional[str] = None
91 104
92 # choose k questions 105 # choose k questions
93 - self.current_topic = topic 106 + self.current_topic = topic_ref
94 self.correct_answers = 0 107 self.correct_answers = 0
95 self.wrong_answers = 0 108 self.wrong_answers = 0
96 - t = self.deps.nodes[topic]  
97 - k = t['choose']  
98 - if t['shuffle_questions']:  
99 - questions = random.sample(t['questions'], k=k) 109 + topic = self.deps.nodes[topic_ref]
  110 + k = topic['choose']
  111 + if topic['shuffle_questions']:
  112 + questions = random.sample(topic['questions'], k=k)
100 else: 113 else:
101 - questions = t['questions'][:k]  
102 - logger.debug(f'selected questions: {", ".join(questions)}') 114 + questions = topic['questions'][:k]
  115 + logger.debug('selected questions: %s', ', '.join(questions))
103 116
104 self.questions: List[Question] = [await self.factory[ref].gen_async() 117 self.questions: List[Question] = [await self.factory[ref].gen_async()
105 for ref in questions] 118 for ref in questions]
106 119
107 - logger.debug(f'generated {len(self.questions)} questions') 120 + logger.debug('generated %s questions', len(self.questions))
108 121
109 # get first question 122 # get first question
110 self.next_question() 123 self.next_question()
111 124
112 # ------------------------------------------------------------------------ 125 # ------------------------------------------------------------------------
113 - # corrects current question  
114 - # updates keys: answer, grade, finish_time, status, tries  
115 - # ------------------------------------------------------------------------  
116 async def check_answer(self, answer) -> None: 126 async def check_answer(self, answer) -> None:
117 - q = self.current_question  
118 - if q is None: 127 + '''
  128 + Corrects current question.
  129 + Updates keys: `answer`, `grade`, `finish_time`, `status`, `tries`
  130 + '''
  131 +
  132 + question = self.current_question
  133 + if question is None:
119 logger.error('check_answer called but current_question is None!') 134 logger.error('check_answer called but current_question is None!')
120 return None 135 return None
121 136
122 - q.set_answer(answer)  
123 - await q.correct_async() # updates q['grade'] 137 + question.set_answer(answer)
  138 + await question.correct_async() # updates q['grade']
124 139
125 - if q['grade'] > 0.999: 140 + if question['grade'] > 0.999:
126 self.correct_answers += 1 141 self.correct_answers += 1
127 - q['status'] = 'right' 142 + question['status'] = 'right'
128 143
129 else: 144 else:
130 self.wrong_answers += 1 145 self.wrong_answers += 1
131 - q['tries'] -= 1  
132 - if q['tries'] > 0:  
133 - q['status'] = 'try_again' 146 + question['tries'] -= 1
  147 + if question['tries'] > 0:
  148 + question['status'] = 'try_again'
134 else: 149 else:
135 - q['status'] = 'wrong' 150 + question['status'] = 'wrong'
136 151
137 - logger.debug(f'ref = {q["ref"]}, status = {q["status"]}') 152 + logger.debug('ref = %s, status = %s',
  153 + question["ref"], question["status"])
138 154
139 # ------------------------------------------------------------------------ 155 # ------------------------------------------------------------------------
140 - # gets next question to show if the status is 'right' or 'wrong',  
141 - # otherwise just returns the current question  
142 - # ------------------------------------------------------------------------  
143 async def get_question(self) -> Optional[Question]: 156 async def get_question(self) -> Optional[Question]:
144 - q = self.current_question  
145 - if q is None: 157 + '''
  158 + Gets next question to show if the status is 'right' or 'wrong',
  159 + otherwise just returns the current question.
  160 + '''
  161 +
  162 + question = self.current_question
  163 + if question is None:
146 logger.error('get_question called but current_question is None!') 164 logger.error('get_question called but current_question is None!')
147 return None 165 return None
148 166
149 - logger.debug(f'{q["ref"]} status = {q["status"]}') 167 + logger.debug('%s status = %s', question["ref"], question["status"])
150 168
151 - if q['status'] == 'right': 169 + if question['status'] == 'right':
152 self.next_question() 170 self.next_question()
153 - elif q['status'] == 'wrong':  
154 - if q['append_wrong']: 171 + elif question['status'] == 'wrong':
  172 + if question['append_wrong']:
155 logger.debug(' wrong answer => append new question') 173 logger.debug(' wrong answer => append new question')
156 - new_question = await self.factory[q['ref']].gen_async() 174 + new_question = await self.factory[question['ref']].gen_async()
157 self.questions.append(new_question) 175 self.questions.append(new_question)
158 self.next_question() 176 self.next_question()
159 177
160 return self.current_question 178 return self.current_question
161 179
162 # ------------------------------------------------------------------------ 180 # ------------------------------------------------------------------------
163 - # moves to next question  
164 - # ------------------------------------------------------------------------  
165 def next_question(self) -> None: 181 def next_question(self) -> None:
  182 + '''
  183 + Moves to next question
  184 + '''
  185 +
166 try: 186 try:
167 - q = self.questions.pop(0) 187 + question = self.questions.pop(0)
168 except IndexError: 188 except IndexError:
169 self.finish_topic() 189 self.finish_topic()
170 return 190 return
171 191
172 - t = self.deps.nodes[self.current_topic]  
173 - q['start_time'] = datetime.now()  
174 - q['tries'] = q.get('max_tries', t['max_tries'])  
175 - q['status'] = 'new'  
176 - self.current_question: Optional[Question] = q 192 + topic = self.deps.nodes[self.current_topic]
  193 + question['start_time'] = datetime.now()
  194 + question['tries'] = question.get('max_tries', topic['max_tries'])
  195 + question['status'] = 'new'
  196 + self.current_question: Optional[Question] = question
177 197
178 # ------------------------------------------------------------------------ 198 # ------------------------------------------------------------------------
179 - # The topic has finished and there are no more questions.  
180 - # The topic level is updated in state and unlocks are performed.  
181 - # The current topic is unchanged.  
182 - # ------------------------------------------------------------------------  
183 def finish_topic(self) -> None: 199 def finish_topic(self) -> None:
184 - logger.debug(f'finished {self.current_topic} in {self.current_course}') 200 + '''
  201 + The topic has finished and there are no more questions.
  202 + The topic level is updated in state and unlocks are performed.
  203 + The current topic is unchanged.
  204 + '''
  205 +
  206 + logger.debug('finished %s in %s', self.current_topic, self.current_course)
185 207
186 self.state[self.current_topic] = { 208 self.state[self.current_topic] = {
187 'date': datetime.now(), 209 'date': datetime.now(),
@@ -194,22 +216,25 @@ class StudentState(object): @@ -194,22 +216,25 @@ class StudentState(object):
194 self.unlock_topics() 216 self.unlock_topics()
195 217
196 # ------------------------------------------------------------------------ 218 # ------------------------------------------------------------------------
197 - # Update proficiency level of the topics using a forgetting factor  
198 - # ------------------------------------------------------------------------  
199 def update_topic_levels(self) -> None: 219 def update_topic_levels(self) -> None:
  220 + '''
  221 + Update proficiency level of the topics using a forgetting factor
  222 + '''
  223 +
200 now = datetime.now() 224 now = datetime.now()
201 - for tref, s in self.state.items():  
202 - dt = now - s['date'] 225 + for tref, state in self.state.items():
  226 + elapsed = now - state['date']
203 try: 227 try:
204 forgetting_factor = self.deps.nodes[tref]['forgetting_factor'] 228 forgetting_factor = self.deps.nodes[tref]['forgetting_factor']
205 - s['level'] *= forgetting_factor ** dt.days # forgetting factor 229 + state['level'] *= forgetting_factor ** elapsed.days
206 except KeyError: 230 except KeyError:
207 - logger.warning(f'Update topic levels: {tref} not in the graph') 231 + logger.warning('Update topic levels: %s not in the graph', tref)
208 232
209 # ------------------------------------------------------------------------ 233 # ------------------------------------------------------------------------
210 - # Unlock topics whose dependencies are satisfied (> min_level)  
211 - # ------------------------------------------------------------------------  
212 def unlock_topics(self) -> None: 234 def unlock_topics(self) -> None:
  235 + '''
  236 + Unlock topics whose dependencies are satisfied (> min_level)
  237 + '''
213 for topic in self.deps.nodes(): 238 for topic in self.deps.nodes():
214 if topic not in self.state: # if locked 239 if topic not in self.state: # if locked
215 pred = self.deps.predecessors(topic) 240 pred = self.deps.predecessors(topic)
@@ -221,7 +246,7 @@ class StudentState(object): @@ -221,7 +246,7 @@ class StudentState(object):
221 'level': 0.0, # unlock 246 'level': 0.0, # unlock
222 'date': datetime.now() 247 'date': datetime.now()
223 } 248 }
224 - logger.debug(f'unlocked "{topic}"') 249 + logger.debug('unlocked "%s"', topic)
225 # else: # lock this topic if deps do not satisfy min_level 250 # else: # lock this topic if deps do not satisfy min_level
226 # del self.state[topic] 251 # del self.state[topic]
227 252
@@ -230,64 +255,78 @@ class StudentState(object): @@ -230,64 +255,78 @@ class StudentState(object):
230 # ======================================================================== 255 # ========================================================================
231 256
232 def topic_has_finished(self) -> bool: 257 def topic_has_finished(self) -> bool:
  258 + '''
  259 + Checks if the all the questions in the current topic have been
  260 + answered.
  261 + '''
233 return self.current_topic is None and self.previous_topic is not None 262 return self.current_topic is None and self.previous_topic is not None
234 263
235 # ------------------------------------------------------------------------ 264 # ------------------------------------------------------------------------
236 - # compute recommended sequence of topics ['a', 'b', ...]  
237 - # ------------------------------------------------------------------------  
238 - def recommend_sequence(self, goals: List[str] = []) -> List[str]:  
239 - G = self.deps  
240 - ts = set(goals)  
241 - for t in goals:  
242 - ts.update(nx.ancestors(G, t)) # include dependencies not in goals 265 + def _recommend_sequence(self, goals: List[str]) -> List[str]:
  266 + '''
  267 + compute recommended sequence of topics ['a', 'b', ...]
  268 + '''
  269 +
  270 + topics = set(goals)
  271 + # include dependencies not in goals
  272 + for topic in goals:
  273 + topics.update(nx.ancestors(self.deps, topic))
243 274
244 todo = [] 275 todo = []
245 - for t in ts:  
246 - level = self.state[t]['level'] if t in self.state else 0.0  
247 - min_level = G.nodes[t]['min_level']  
248 - if t in goals or level < min_level:  
249 - todo.append(t) 276 + for topic in topics:
  277 + level = self.state[topic]['level'] if topic in self.state else 0.0
  278 + min_level = self.deps.nodes[topic]['min_level']
  279 + if topic in goals or level < min_level:
  280 + todo.append(topic)
250 281
251 - logger.debug(f' {len(ts)} total topics, {len(todo)} listed ') 282 + logger.debug(' %s total topics, %s listed ', len(topics), len(todo))
252 283
253 # FIXME topological sort is a poor way to sort topics 284 # FIXME topological sort is a poor way to sort topics
254 - tl = list(nx.topological_sort(G.subgraph(todo))) 285 + topic_seq = list(nx.topological_sort(self.deps.subgraph(todo)))
255 286
256 # sort with unlocked first 287 # sort with unlocked first
257 - unlocked = [t for t in tl if t in self.state]  
258 - locked = [t for t in tl if t not in unlocked] 288 + unlocked = [t for t in topic_seq if t in self.state]
  289 + locked = [t for t in topic_seq if t not in unlocked]
259 return unlocked + locked 290 return unlocked + locked
260 291
261 # ------------------------------------------------------------------------ 292 # ------------------------------------------------------------------------
262 def get_current_question(self) -> Optional[Question]: 293 def get_current_question(self) -> Optional[Question]:
  294 + '''gets current question'''
263 return self.current_question 295 return self.current_question
264 296
265 # ------------------------------------------------------------------------ 297 # ------------------------------------------------------------------------
266 def get_current_topic(self) -> Optional[str]: 298 def get_current_topic(self) -> Optional[str]:
  299 + '''gets current topic'''
267 return self.current_topic 300 return self.current_topic
268 301
269 # ------------------------------------------------------------------------ 302 # ------------------------------------------------------------------------
270 def get_previous_topic(self) -> Optional[str]: 303 def get_previous_topic(self) -> Optional[str]:
  304 + '''gets previous topic'''
271 return self.previous_topic 305 return self.previous_topic
272 306
273 # ------------------------------------------------------------------------ 307 # ------------------------------------------------------------------------
274 def get_current_course_title(self) -> str: 308 def get_current_course_title(self) -> str:
  309 + '''gets current course title'''
275 return str(self.courses[self.current_course]['title']) 310 return str(self.courses[self.current_course]['title'])
276 311
277 # ------------------------------------------------------------------------ 312 # ------------------------------------------------------------------------
278 def get_current_course_id(self) -> Optional[str]: 313 def get_current_course_id(self) -> Optional[str]:
  314 + '''gets current course id'''
279 return self.current_course 315 return self.current_course
280 316
281 # ------------------------------------------------------------------------ 317 # ------------------------------------------------------------------------
282 def is_locked(self, topic: str) -> bool: 318 def is_locked(self, topic: str) -> bool:
  319 + '''checks if a given topic is locked'''
283 return topic not in self.state 320 return topic not in self.state
284 321
285 # ------------------------------------------------------------------------ 322 # ------------------------------------------------------------------------
286 - # Return list of {ref: 'xpto', name: 'long name', leve: 0.5}  
287 - # Levels are in the interval [0, 1] if unlocked or None if locked.  
288 - # Topics unlocked but not yet done have level 0.0.  
289 - # ------------------------------------------------------------------------  
290 def get_knowledge_state(self): 323 def get_knowledge_state(self):
  324 + '''
  325 + Return list of {ref: 'xpto', name: 'long name', leve: 0.5}
  326 + Levels are in the interval [0, 1] if unlocked or None if locked.
  327 + Topics unlocked but not yet done have level 0.0.
  328 + '''
  329 +
291 return [{ 330 return [{
292 'ref': ref, 331 'ref': ref,
293 'type': self.deps.nodes[ref]['type'], 332 'type': self.deps.nodes[ref]['type'],
@@ -297,19 +336,16 @@ class StudentState(object): @@ -297,19 +336,16 @@ class StudentState(object):
297 336
298 # ------------------------------------------------------------------------ 337 # ------------------------------------------------------------------------
299 def get_topic_progress(self) -> float: 338 def get_topic_progress(self) -> float:
  339 + '''computes progress of the current topic'''
300 return self.correct_answers / (1 + self.correct_answers + 340 return self.correct_answers / (1 + self.correct_answers +
301 len(self.questions)) 341 len(self.questions))
302 342
303 # ------------------------------------------------------------------------ 343 # ------------------------------------------------------------------------
304 def get_topic_level(self, topic: str) -> float: 344 def get_topic_level(self, topic: str) -> float:
  345 + '''gets level of a given topic'''
305 return float(self.state[topic]['level']) 346 return float(self.state[topic]['level'])
306 347
307 # ------------------------------------------------------------------------ 348 # ------------------------------------------------------------------------
308 def get_topic_date(self, topic: str): 349 def get_topic_date(self, topic: str):
  350 + '''gets date of a given topic'''
309 return self.state[topic]['date'] 351 return self.state[topic]['date']
310 -  
311 - # ------------------------------------------------------------------------  
312 - # Recommends a topic to practice/learn from the state.  
313 - # ------------------------------------------------------------------------  
314 - # def get_recommended_topic(self): # FIXME untested  
315 - # return min(self.state.items(), key=lambda x: x[1]['level'])[0]  
aprendizations/templates/courses.html
1 {% autoescape %} 1 {% autoescape %}
2 -<!doctype html>  
3 -<html lang="pt-PT">  
4 2
5 -<head>  
6 - <title>{{appname}}</title>  
7 - <link rel="icon" href="/static/favicon.ico">  
8 - <meta charset="utf-8">  
9 - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 3 +<!DOCTYPE html>
  4 +<html lang="pt-PT">
  5 + <head>
  6 + <meta charset="utf-8" />
  7 + <meta name="viewport" content="width=device-width, initial-scale=1">
10 <meta name="author" content="Miguel Barão"> 8 <meta name="author" content="Miguel Barão">
  9 + <link rel="icon" href="favicon.ico">
11 <!-- Styles --> 10 <!-- Styles -->
12 - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">  
13 - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">  
14 - <link rel="stylesheet" href="/static/css/maintopics.css">  
15 - <link rel="stylesheet" href="/static/css/sticky-footer-navbar.css"> 11 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  12 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
  13 + <link rel="stylesheet" href="{{static_url('css/sticky-footer-navbar.css')}}">
16 <!-- Scripts --> 14 <!-- Scripts -->
17 - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>  
18 - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>  
19 - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>  
20 - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>  
21 - <script defer src="/static/fontawesome-free/js/all.min.js"></script>  
22 - <script defer src="/static/js/maintopics.js"></script>  
23 -</head> 15 + <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  16 + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
  17 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  18 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
  19 +
  20 + <title>{{appname}}</title>
  21 + </head>
24 22
25 -<body>  
26 - <!-- ===== navbar ==================================================== -->  
27 - <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">  
28 - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">  
29 - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">  
30 - <span class="navbar-toggler-icon"></span> 23 + <body>
  24 + <!-- ===== navbar ======================================================== -->
  25 + <nav class="navbar navbar-expand-sm navbar-dark bg-primary fixed-top shadow">
  26 + <div class="container-fluid">
  27 + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora">
  28 + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  29 + <span class="navbar-toggler-icon"></span>
31 </button> 30 </button>
32 - <div class="collapse navbar-collapse" id="navbarText">  
33 - <div class="navbar-nav mr-auto">  
34 - <a class="nav-item nav-link active" href="#">Cursos <span class="sr-only">(actual)</span></a>  
35 - <a class="nav-item nav-link disabled" href="#">Tópicos</a>  
36 - <a class="nav-item nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Classificação</a>  
37 - </div>  
38 - <ul class="navbar-nav">  
39 - <li class="nav-item dropdown">  
40 - <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">  
41 - <i class="fas fa-user-graduate" aria-hidden="true"></i>  
42 - <span id="name">{{ escape(name) }}</span>  
43 - <span class="caret"></span>  
44 - </a>  
45 - <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">  
46 - <a class="dropdown-item" data-toggle="modal" data-target="#password_modal">Mudar Password</a>  
47 - <div class="dropdown-divider"></div>  
48 - <a class="dropdown-item" href="/logout">Sair</a>  
49 - </div>  
50 - </li>  
51 - </ul> 31 +
  32 + <div class="collapse navbar-collapse" id="navbarNavText">
  33 + <ul class="navbar-nav">
  34 + <li class="nav-item"><a class="nav-link active" aria-current="page" href="/courses">Cursos</a></li>
  35 + <li class="nav-item"><a class="nav-link disabled" href="#">Tópicos</a></li>
  36 + <li class="nav-item"><a class="nav-link disabled" href="#">Classificação</a></li>
  37 + </ul>
  38 + <ul class="navbar-nav ms-auto">
  39 + <li class="nav-item dropdown">
  40 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  41 + <i class="fas fa-user-graduate" aria-hidden="true"></i>
  42 + &nbsp;
  43 + <span id="name">{{ escape(name) }}</span>
  44 + <span class="caret"></span>
  45 + </a>
  46 + <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
  47 + <li><a class="dropdown-item" data-bs-toggle="modal" data-bs-target="#password_modal">Mudar Password</a></li>
  48 + <li><hr class="dropdown-divider"></li>
  49 + <li><a class="dropdown-item" href="/logout">Sair</a></li>
  50 + </ul>
  51 + </li>
  52 + </ul>
52 </div> 53 </div>
  54 + </div>
53 </nav> 55 </nav>
54 - <!-- ===== page ====================================================== -->  
55 - <div class="container">  
56 - <div id="notifications"></div>  
57 - <div class="row justify-content-left">  
58 - {% for k,v in courses.items() %}  
59 - <div class="card-deck col-md-4">  
60 - <a href="/course/{{k}}" class="card mb-3">  
61 - <div class="card-body">  
62 - <h6 class="card-title">{{ v['title'] }}</h6>  
63 - <p class="card-text">{{ v.get('description', '') }}</p>  
64 - </div>  
65 - </a> 56 + <!-- === Change Password Modal =========================================== -->
  57 + <div id="password_modal" class="modal fade" tabindex="-1" aria-labelledby="password_modal" aria-hidden="true">
  58 + <div class="modal-dialog">
  59 + <div class="modal-content">
  60 + <!-- header -->
  61 + <div class="modal-header">
  62 + <h5 class="modal-title">Alterar Password</h5>
  63 + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  64 + </div>
  65 + <!-- body -->
  66 + <div class="modal-body">
  67 + <div class="control-group">
  68 + <label for="new_password" class="control-label">Introduza a nova password:</label>
  69 + <div class="controls">
  70 + <input type="password" id="new_password" name="new_password" autocomplete="new-password">
  71 + </div>
66 </div> 72 </div>
67 - {% end %} <!-- for --> 73 + </div>
  74 + <!-- footer -->
  75 + <div class="modal-footer">
  76 + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
  77 + <button id="change_password" type="button" class="btn btn-primary" data-bs-dismiss="modal">Alterar</button>
  78 + </div>
  79 + <!-- end -->
68 </div> 80 </div>
  81 + </div>
69 </div> 82 </div>
70 83
71 - <footer class="footer">  
72 - <div class="container">  
73 - <small class="text-muted">  
74 - <a href="mailto:mjsb@uevora.pt?subject=Encontrei um erro&body=Descreva detalhadamente a situação na qual encontrou o erro. Indique qual o curso, tópico e pergunta. No caso de problemas técnicos indique também qual o seu sistema operativo e browser.">Reportar erros</a>  
75 - &nbsp;/&nbsp;  
76 - <a href="mailto:mjsb@uevora.pt?subject=Sugestões">Enviar sugestões</a>  
77 - </small>  
78 - </div>  
79 - </footer>  
80 -  
81 - <!-- === Change Password Modal =========================================== -->  
82 - <div id="password_modal" class="modal fade" tabindex="-1" role="dialog">  
83 - <div class="modal-dialog" role="document">  
84 - <div class="modal-content">  
85 - <!-- header -->  
86 - <div class="modal-header">  
87 - <h5 class="modal-title">Alterar Password</h5>  
88 - </div>  
89 - <!-- body -->  
90 - <div class="modal-body">  
91 - <div class="control-group">  
92 - <label for="new_password" class="control-label">Introduza a nova password:</label>  
93 - <div class="controls">  
94 - <input type="password" id="new_password" name="new_password" autocomplete="new-password">  
95 - </div>  
96 - </div>  
97 - </div>  
98 - <!-- footer -->  
99 - <div class="modal-footer">  
100 - <button type="button" class="btn btn-default" data-dismiss="modal">Cancelar</button>  
101 - <button id="change_password" type="button" class="btn btn-danger" data-dismiss="modal">Alterar</button>  
102 - </div>  
103 - </div><!-- /.modal-content -->  
104 - </div><!-- /.modal-dialog -->  
105 - </div><!-- /.modal -->  
106 -</body> 84 + <!-- ===== page ========================================================== -->
  85 + <div class="container">
  86 + <div id="notifications" style="position:fixed; z-index: 999;"></div>
107 87
  88 + <div class="row row-cols-1 row-cols-md-3 g-4">
  89 + {% for k,v in courses.items() %}
  90 + <div class="col">
  91 + <div class="card bg-light shadow">
  92 + <div class="card-body">
  93 + <h5>{{ v['title'] }}</h5>
  94 + <p class="card-text">{{ v.get('description', '') }}</p>
  95 + <a href="/course/{{k}}" class="stretched-link">Iniciar</a>
  96 + </div>
  97 + </div>
  98 + </div>
  99 + {% end %}
  100 + </div>
  101 + </div>
  102 + </body>
108 </html> 103 </html>
aprendizations/templates/login.html
1 -<!doctype html>  
2 -<html lang="pt">  
3 -<head>  
4 - <title>{{appname}}</title>  
5 - <link rel="icon" href="/static/favicon.ico">  
6 -  
7 - <meta charset="utf-8">  
8 - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">  
9 - <meta name="author" content="Miguel Barão">  
10 -  
11 - <!-- Styles -->  
12 - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">  
13 - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">  
14 -  
15 - <!-- Scripts -->  
16 - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>  
17 - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>  
18 - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>  
19 - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>  
20 - <script defer src="/static/fontawesome-free/js/all.min.js"></script>  
21 -  
22 -</head>  
23 -<!-- =================================================================== -->  
24 -<body>  
25 - <div class="container-fluid">  
26 - <div class="card bg-light border-dark mt-3">  
27 - <div class="card-body">  
28 - <div class="row">  
29 -  
30 - <div class="col-sm-9">  
31 - <img src="/static/logo_horizontal_login.png" class="img-responsive mb-3" width="50%" alt="Universidade de Évora">  
32 - </div>  
33 -  
34 - <div class="col-sm-3">  
35 -  
36 - <form method="post" action="/login" class="form-signin">  
37 - {% module xsrf_form_html() %}  
38 - <div class="form-group">  
39 - <input type="text" name="uid" class="form-control mb-3" placeholder="Número de aluno" required autofocus>  
40 - <input type="password" name="pw" class="form-control mb-3" placeholder="Senha" required>  
41 - <p class="text-danger"> {{ error }} </p>  
42 - <button class="btn btn-primary" type="submit">  
43 - Entrar  
44 - </button>  
45 - </div>  
46 - </form>  
47 - </div>  
48 -  
49 - </div> <!-- row -->  
50 - </div> <!-- card-body -->  
51 - </div> <!-- card -->  
52 - </div> <!-- container -->  
53 -</body> 1 +<!DOCTYPE html>
  2 +<html lang="en">
  3 + <head>
  4 + <meta charset="utf-8" />
  5 + <meta name="viewport" content="width=device-width, initial-scale=1" />
  6 + <meta name="author" content="Miguel Barão">
  7 +
  8 + <!-- <link rel="canonical" href="https://getbootstrap.com/docs/5.1/examples/sign-in/"> -->
  9 +
  10 + <!-- Bootstrap core CSS -->
  11 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  12 + <!-- <link href="../assets/dist/css/bootstrap.min.css" rel="stylesheet"> -->
  13 +
  14 + <style>
  15 + .bd-placeholder-img {
  16 + font-size: 1.125rem;
  17 + text-anchor: middle;
  18 + -webkit-user-select: none;
  19 + -moz-user-select: none;
  20 + user-select: none;
  21 + }
  22 +
  23 + @media (min-width: 768px) {
  24 + .bd-placeholder-img-lg {
  25 + font-size: 3.5rem;
  26 + }
  27 + }
  28 + </style>
  29 +
  30 + <link href="{{static_url('css/signin.css')}}" rel="stylesheet">
  31 +
  32 + <title>{{appname}}</title>
  33 + </head>
  34 + <body class="text-center">
  35 +
  36 + <main class="form-signin">
  37 + <form method="post" action="/login" class="form-signin">
  38 + {% module xsrf_form_html() %}
  39 + <img class="mb-4" src="{{ static_url('logo_horizontal_login.png') }}" alt="Universidade de Évora" height="80">
  40 +
  41 + <div class="form-floating">
  42 + <input type="text" class="form-control" id="uid" name="uid" placeholder="99999" required autofocus>
  43 + <label for="uid">Número de aluno</label>
  44 + </div>
  45 + <div class="form-floating">
  46 + <input type="password" class="form-control" id="pw" name="pw" placeholder="Senha" required>
  47 + <label for="pw">Senha</label>
  48 + </div>
  49 +
  50 + <p class="text-danger"> {{ error }} </p>
  51 +
  52 + <button class="w-100 btn btn-lg btn-primary" type="submit">Entrar</button>
  53 + </form>
  54 + </main>
  55 +
  56 + </body>
54 </html> 57 </html>
aprendizations/templates/maintopics-table.html
1 {% autoescape %} 1 {% autoescape %}
2 2
3 -<!doctype html> 3 +<!DOCTYPE html>
4 <html lang="pt"> 4 <html lang="pt">
5 <head> 5 <head>
6 - <title>{{appname}}</title>  
7 - <link rel="icon" href="/static/favicon.ico">  
8 -  
9 - <meta charset="utf-8">  
10 - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 6 + <meta charset="utf-8" />
  7 + <meta name="viewport" content="width=device-width, initial-scale=1" />
11 <meta name="author" content="Miguel Barão"> 8 <meta name="author" content="Miguel Barão">
  9 + <link rel="icon" href="/static/favicon.ico">
12 10
13 <!-- Styles --> 11 <!-- Styles -->
14 - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">  
15 - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">  
16 - <link rel="stylesheet" href="/static/css/maintopics.css"> 12 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  13 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
17 14
18 <!-- Scripts --> 15 <!-- Scripts -->
19 - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>  
20 - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>  
21 - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>  
22 - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>  
23 - <script defer src="/static/fontawesome-free/js/all.min.js"></script>  
24 - <script defer src="/static/js/maintopics.js"></script> 16 + <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  17 + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
25 18
  19 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  20 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
  21 +
  22 + <title>{{appname}}</title>
26 </head> 23 </head>
27 <!-- ===================================================================== --> 24 <!-- ===================================================================== -->
28 <body> 25 <body>
29 -<nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">  
30 - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">  
31 - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">  
32 - <span class="navbar-toggler-icon"></span>  
33 - </button>  
34 -  
35 - <div class="collapse navbar-collapse" id="navbarText">  
36 - <div class="navbar-nav mr-auto">  
37 - <a class="nav-item nav-link" href="/courses">Cursos</a>  
38 - <a class="nav-item nav-link active" href="#">Tópicos <span class="sr-only">(actual)</span></a>  
39 - <a class="nav-item nav-link" href="/rankings?course={{course_id}}">Classificação</a> 26 +<nav class="navbar navbar-expand-sm navbar-dark bg-primary fixed-top shadow">
  27 + <div class="container-fluid">
  28 + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora">
  29 + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  30 + <span class="navbar-toggler-icon"></span>
  31 + </button>
  32 +
  33 + <div class="collapse navbar-collapse" id="navbarNavText">
  34 + <ul class="navbar-nav">
  35 + <li class="nav-item"><a class="nav-link" href="/courses">Cursos</a></li>
  36 + <li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Tópicos</a></li>
  37 + <li class="nav-item"><a class="nav-link" href="/rankings?course={{course_id}}">Classificação</a></li>
  38 + </ul>
  39 + <ul class="navbar-nav ms-auto">
  40 + <li class="nav-item dropdown">
  41 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  42 + <i class="fas fa-user-graduate" aria-hidden="true"></i>
  43 + &nbsp;
  44 + <span id="name">{{ escape(name) }}</span>
  45 + <span class="caret"></span>
  46 + </a>
  47 + <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
  48 + <li><a class="dropdown-item" data-bs-toggle="modal" data-bs-target="#password_modal">Mudar Password</a></li>
  49 + <li><hr class="dropdown-divider"></li>
  50 + <li><a class="dropdown-item" href="/logout">Sair</a></li>
  51 + </ul>
  52 + </li>
  53 + </ul>
40 </div> 54 </div>
  55 + </div>
  56 +</nav>
41 57
42 - <ul class="navbar-nav">  
43 - <li class="nav-item dropdown">  
44 - <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">  
45 - <i class="fas fa-user-graduate" aria-hidden="true"></i>  
46 - <span id="name">{{ escape(name) }}</span>  
47 - <span class="caret"></span>  
48 - </a>  
49 - <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">  
50 - <a class="dropdown-item" data-toggle="modal" data-target="#password_modal">Mudar Password</a>  
51 - <div class="dropdown-divider"></div>  
52 - <a class="dropdown-item" href="/logout">Sair</a> 58 +<!-- === Change Password Modal =========================================== -->
  59 +<div id="password_modal" class="modal fade" tabindex="-1" aria-labelledby="password_modal" aria-hidden="true">
  60 + <div class="modal-dialog">
  61 + <div class="modal-content">
  62 + <!-- header -->
  63 + <div class="modal-header">
  64 + <h5 class="modal-title">Alterar Password</h5>
  65 + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  66 + </div>
  67 + <!-- body -->
  68 + <div class="modal-body">
  69 + <div class="control-group">
  70 + <label for="new_password" class="control-label">Introduza a nova password:</label>
  71 + <div class="controls">
  72 + <input type="password" id="new_password" name="new_password" autocomplete="new-password">
  73 + </div>
53 </div> 74 </div>
54 - </li>  
55 - </ul> 75 + </div>
  76 + <!-- footer -->
  77 + <div class="modal-footer">
  78 + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
  79 + <button id="change_password" type="button" class="btn btn-primary" data-bs-dismiss="modal">Alterar</button>
  80 + </div>
  81 + <!-- end -->
  82 + </div>
56 </div> 83 </div>
57 -</nav> 84 +</div>
  85 +
58 <!-- ===================================================================== --> 86 <!-- ===================================================================== -->
59 <div class="container"> 87 <div class="container">
60 88
61 <div id="notifications"></div> 89 <div id="notifications"></div>
62 90
63 - <div class="alert alert-warning alert-dismissible fade show" role="warning">  
64 - <h5 class="my-3">Legenda:</h5>  
65 - <dl class="row ml-3">  
66 - <dt class="my-0 col-sm-1"><i class="fas fa-book"></i></dt>  
67 - <dd class="my-0 col-sm-11">Material de estudo</dd>  
68 - <dt class="my-0 col-sm-1"><i class="fas fa-pencil-alt"></i></dt>  
69 - <dd class="my-0 col-sm-11">Exercícios</dd>  
70 - <dt class="my-0 col-sm-1"><i class="fas fa-puzzle-piece"></i></dt>  
71 - <dd class="my-0 col-sm-11">Não faz parte deste curso mas é necessário saber</dd>  
72 - <dt class="my-0 col-sm-1"><i class="fas fa-flag"></i></dt>  
73 - <dd class="my-0 col-sm-11">Milestone (terminou um certo conjunto de tópicos)</dd>  
74 - </dl>  
75 - <button type="button" class="close" data-dismiss="alert" aria-label="Close">  
76 - <span aria-hidden="true">&times;</span>  
77 - </button>  
78 - </div>  
79 -  
80 - <h1 class="display-5 p-4">{{ course['title'] }}</h1> 91 + <h1 class="display-6">{{ course['title'] }}</h1>
81 92
82 <table class="table table-hover"> 93 <table class="table table-hover">
83 - <thead class=""> 94 + <thead>
84 <tr> 95 <tr>
85 - <th>Tópico</th>  
86 - <th class="text-center">Nível</th> 96 + <th scope="col"></th>
  97 + <th scope="col">Tópico</th>
  98 + <th scope="col" class="text-center">Estado</th>
87 </tr> 99 </tr>
88 </thead> 100 </thead>
89 <tbody> 101 <tbody>
90 {% for t in state %} 102 {% for t in state %}
91 <!-- ------------------------------------------------------------- --> 103 <!-- ------------------------------------------------------------- -->
92 {% if t['level'] is None %} 104 {% if t['level'] is None %}
93 - <tr class="table-secondary"> 105 + <tr>
  106 + <th scope="row" class="text-muted text-center">
  107 + {% if t['type']=='chapter' %}
  108 + <i class="fas fa-flag"></i>
  109 + {% elif t['type']=='learn' %}
  110 + <i class="fas fa-book"></i>&nbsp;
  111 + {% else %}
  112 + <i class="fas fa-pencil-alt"></i>&nbsp;
  113 + {% end %}
  114 + </th>
94 <td> 115 <td>
95 <div class="text-muted"> 116 <div class="text-muted">
96 {% if t['ref'] not in course['goals'] %} 117 {% if t['ref'] not in course['goals'] %}
97 <i class="fas fa-puzzle-piece"></i> 118 <i class="fas fa-puzzle-piece"></i>
98 {% end %} 119 {% end %}
99 120
100 - {% if t['type']=='chapter' %}  
101 - <i class="fas fa-flag"></i>  
102 - {% elif t['type']=='learn' %}  
103 - <i class="fas fa-book"></i>&nbsp;  
104 - {% else %}  
105 - <i class="fas fa-pencil-alt"></i>&nbsp;  
106 - {% end %}  
107 121
108 {{ t['name'] }} 122 {{ t['name'] }}
109 </div> 123 </div>
@@ -116,11 +130,7 @@ @@ -116,11 +130,7 @@
116 {% else %} 130 {% else %}
117 131
118 <tr class="clickable-row " data-href="/topic/{{t['ref']}}"> 132 <tr class="clickable-row " data-href="/topic/{{t['ref']}}">
119 - <td class="text-primary">  
120 - {% if t['ref'] not in course['goals'] %}  
121 - <i class="fas fa-puzzle-piece"></i>  
122 - {% end %}  
123 - 133 + <th scope="row" class="text-primary text-center">
124 {% if t['type']=='chapter' %} 134 {% if t['type']=='chapter' %}
125 <i class="fas fa-flag-checkered"></i>&nbsp; 135 <i class="fas fa-flag-checkered"></i>&nbsp;
126 {% elif t['type']=='learn' %} 136 {% elif t['type']=='learn' %}
@@ -128,6 +138,11 @@ @@ -128,6 +138,11 @@
128 {% else %} 138 {% else %}
129 <i class="fas fa-pencil-alt"></i>&nbsp; 139 <i class="fas fa-pencil-alt"></i>&nbsp;
130 {% end %} 140 {% end %}
  141 + </th>
  142 + <td class="text-primary">
  143 + {% if t['ref'] not in course['goals'] %}
  144 + <i class="fas fa-puzzle-piece"></i>
  145 + {% end %}
131 146
132 {{ t['name'] }} 147 {{ t['name'] }}
133 </td> 148 </td>
@@ -150,34 +165,5 @@ @@ -150,34 +165,5 @@
150 </tbody> 165 </tbody>
151 </table> 166 </table>
152 </div> 167 </div>
153 -  
154 -  
155 -<!-- === Change Password Modal =========================================== -->  
156 -<div id="password_modal" class="modal fade" tabindex="-1" role="dialog">  
157 - <div class="modal-dialog" role="document">  
158 - <div class="modal-content">  
159 -<!-- header -->  
160 - <div class="modal-header">  
161 - <h5 class="modal-title">Alterar Password</h5>  
162 - </div>  
163 -<!-- body -->  
164 - <div class="modal-body">  
165 - <div class="control-group">  
166 - <label for="new_password" class="control-label">Introduza a nova password:</label>  
167 - <div class="controls">  
168 - <input type="password" id="new_password" name="new_password" autocomplete="new-password">  
169 - </div>  
170 - </div>  
171 - </div>  
172 -<!-- footer -->  
173 - <div class="modal-footer">  
174 - <button type="button" class="btn btn-default" data-dismiss="modal">Cancelar</button>  
175 - <button id="change_password" type="button" class="btn btn-danger" data-dismiss="modal">Alterar</button>  
176 - </div>  
177 -  
178 - </div><!-- /.modal-content -->  
179 - </div><!-- /.modal-dialog -->  
180 -</div><!-- /.modal -->  
181 -  
182 </body> 168 </body>
183 </html> 169 </html>
aprendizations/templates/question-checkbox.html
@@ -3,20 +3,14 @@ @@ -3,20 +3,14 @@
3 3
4 {% block answer %} 4 {% block answer %}
5 <fieldset data-role="controlgroup"> 5 <fieldset data-role="controlgroup">
6 - <div class="list-group">  
7 - {% for n,opt in enumerate(question['options']) %}  
8 - <a class="list-group-item list-group-item-action">  
9 - <div class="custom-control custom-checkbox">  
10 - <input type="checkbox" class="custom-control-input"  
11 - id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">  
12 - <label for="{{ n }}" class="custom-control-label">  
13 - {{ md(opt, strip_p_tag=True) }}  
14 - </label>  
15 - </div>  
16 - </a>  
17 - {% end %}  
18 - </div> 6 + <div class="list-group">
  7 + {% for n,opt in enumerate(question['options']) %}
  8 + <label class="list-group-item list-group-item-action">
  9 + <input type="checkbox" class="form-check-input" id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
  10 + {{ md(opt, strip_p_tag=True) }}
  11 + </label>
  12 + {% end %}
  13 + </div>
19 </fieldset> 14 </fieldset>
20 <input type="hidden" name="qid" value="{{ question['qid'] }}"> 15 <input type="hidden" name="qid" value="{{ question['qid'] }}">
21 -  
22 -{% end %}  
23 \ No newline at end of file 16 \ No newline at end of file
  17 +{% end %}
aprendizations/templates/question-information.html
1 {% autoescape %} 1 {% autoescape %}
2 2
3 -<h2 class="page-header">{{ question['title'] }}</h4> 3 +<h1 class="display-6">{{ question['title'] }}</h1>
4 4
5 <div id="text"> 5 <div id="text">
6 {{ md(question['text']) }} 6 {{ md(question['text']) }}
aprendizations/templates/question-radio.html
@@ -3,19 +3,16 @@ @@ -3,19 +3,16 @@
3 3
4 {% block answer %} 4 {% block answer %}
5 <fieldset data-role="controlgroup"> 5 <fieldset data-role="controlgroup">
6 - <div class="list-group">  
7 - {% for n,opt in enumerate(question['options']) %}  
8 - <a class="list-group-item list-group-item-action">  
9 - <div class="custom-control custom-radio">  
10 - <input type="radio" class="custom-control-input"  
11 - id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">  
12 - <label for="{{ n }}" class="custom-control-label">  
13 - {{ md(opt, strip_p_tag=True) }}  
14 - </label>  
15 - </div>  
16 - </a>  
17 - {% end %}  
18 - </div> 6 + <div class="list-group">
  7 + {% for n,opt in enumerate(question['options']) %}
  8 + <label class="list-group-item list-group-item-action">
  9 + <input type="radio" class="form-check-input" id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
  10 + <label for="{{ n }}" class="custom-control-label">
  11 + {{ md(opt, strip_p_tag=True) }}
  12 + </label>
  13 + </label>
  14 + {% end %}
  15 + </div>
19 </fieldset> 16 </fieldset>
20 <input type="hidden" name="qid" value="{{ question['qid'] }}"> 17 <input type="hidden" name="qid" value="{{ question['qid'] }}">
21 -{% end %}  
22 \ No newline at end of file 18 \ No newline at end of file
  19 +{% end %}
aprendizations/templates/question.html
1 {% autoescape %} 1 {% autoescape %}
2 2
3 -<h2 class="page-header">{{ md(question['title']) }}</h4> 3 +<h1 class="display-6">{{ md(question['title']) }}</h1>
4 4
5 <div id="text"> 5 <div id="text">
6 {{ md(question['text']) }} 6 {{ md(question['text']) }}
@@ -8,4 +8,4 @@ @@ -8,4 +8,4 @@
8 8
9 {% block answer %}{% end %} 9 {% block answer %}{% end %}
10 10
11 -<p class="text-right font-italic" id="tries"></p> 11 +<p class="text-end"><em id="tries"></em></p>
aprendizations/templates/rankings.html
1 {% autoescape %} 1 {% autoescape %}
2 2
3 -<!doctype html> 3 +<!DOCTYPE html>
4 <html lang="pt"> 4 <html lang="pt">
5 <head> 5 <head>
6 - <title>{{appname}}</title>  
7 - <link rel="icon" href="/static/favicon.ico">  
8 -  
9 - <meta charset="utf-8">  
10 - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 6 + <meta charset="utf-8" />
  7 + <meta name="viewport" content="width=device-width, initial-scale=1" />
11 <meta name="author" content="Miguel Barão"> 8 <meta name="author" content="Miguel Barão">
  9 + <link rel="icon" href="/static/favicon.ico">
  10 + <!-- Styles -->
  11 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  12 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
  13 + <!-- Scripts -->
  14 + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
  15 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  16 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
12 17
13 -<!-- Styles -->  
14 - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">  
15 - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">  
16 - <link rel="stylesheet" href="/static/css/maintopics.css">  
17 -  
18 -<!-- Scripts -->  
19 - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>  
20 - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>  
21 - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>  
22 - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>  
23 - <script defer src="/static/fontawesome-free/js/all.min.js"></script>  
24 - <script defer src="/static/js/maintopics.js"></script>  
25 - 18 + <title>{{appname}}</title>
26 </head> 19 </head>
27 <!-- ===================================================================== --> 20 <!-- ===================================================================== -->
28 <body> 21 <body>
29 -<nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">  
30 - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">  
31 - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">  
32 - <span class="navbar-toggler-icon"></span>  
33 - </button> 22 +<nav class="navbar navbar-expand-sm navbar-dark bg-primary fixed-top">
  23 + <div class="container-fluid">
  24 + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora">
  25 + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  26 + <span class="navbar-toggler-icon"></span>
  27 + </button>
34 28
35 - <div class="collapse navbar-collapse" id="navbarText">  
36 - <div class="navbar-nav mr-auto">  
37 - <a class="nav-item nav-link" href="/courses">Cursos <span class="sr-only">(actual)</span></a>  
38 - <a class="nav-item nav-link" href="/course/{{course_id}}">Tópicos</a>  
39 - <a class="nav-item nav-link active" href="#">Classificação</a> 29 + <div class="collapse navbar-collapse" id="navbarNavText">
  30 + <ul class="navbar-nav">
  31 + <li class="nav-item"><a class="nav-link" href="/courses">Cursos</a></li>
  32 + <li class="nav-item"><a class="nav-link" href="/course/{{course_id}}">Tópicos</a></li>
  33 + <li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Classificação</a></li>
  34 + </ul>
  35 + <ul class="navbar-nav ms-auto">
  36 + <li class="nav-item dropdown">
  37 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  38 + <i class="fas fa-user-graduate" aria-hidden="true"></i>
  39 + &nbsp;
  40 + <span id="name">{{ escape(name) }}</span>
  41 + <span class="caret"></span>
  42 + </a>
  43 + <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
  44 + <li><a class="dropdown-item" href="/logout">Sair</a></li>
  45 + </ul>
  46 + </li>
  47 + </ul>
40 </div> 48 </div>
41 -  
42 - <ul class="navbar-nav">  
43 - <li class="nav-item dropdown">  
44 - <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">  
45 - <i class="fas fa-user-graduate" aria-hidden="true"></i>  
46 - <span id="name">{{ escape(name) }}</span>  
47 - <span class="caret"></span>  
48 - </a>  
49 - <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">  
50 - <a class="dropdown-item" data-toggle="modal" data-target="#password_modal">Mudar Password</a>  
51 - <div class="dropdown-divider"></div>  
52 - <a class="dropdown-item" href="/logout">Sair</a>  
53 - </div>  
54 - </li>  
55 - </ul>  
56 </div> 49 </div>
57 </nav> 50 </nav>
  51 +
58 <!-- ===================================================================== --> 52 <!-- ===================================================================== -->
59 <div class="container"> 53 <div class="container">
60 -<h4>{{course_title}}</h4> 54 + <h1 class="display-6">{{ course_title }}</h1>
  55 +
61 <table class="table table-hover"> 56 <table class="table table-hover">
62 <col width="100"> 57 <col width="100">
63 <thead> 58 <thead>
64 <tr> 59 <tr>
65 - <th># Posição</th>  
66 - <th>Aluno</th>  
67 - <th>Progresso</th> 60 + <th scope="col" class="text-center">Posição</th>
  61 + <th scope="col">Aluno</th>
  62 + <th scope="col"></th>
  63 + <th scope="col">Progresso</th>
68 </tr> 64 </tr>
69 </thead> 65 </thead>
70 <tbody> 66 <tbody>
71 {% for i,r in enumerate(rankings) %} 67 {% 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 - {{ '<i class="fas fa-bug" title="Menos de 50% de respostas correctas" ></i>' if 0.0 < r[3] < 0.5 else '' }}  
87 - </td>  
88 - <td> <!-- progress -->  
89 - <div class="progress">  
90 - <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>  
91 - </div>  
92 - </td>  
93 - </tr> 68 + <tr class="{{ 'table-primary' if r[0] == uid else '' }}">
  69 + <td class="text-center"> <!-- rank -->
  70 + <strong>
  71 + {{ '<i class="fas fa-crown fa-lg text-warning"></i>' if i==0 else i+1 }}
  72 + </strong>
  73 + </td>
  74 + <td> <!-- student name -->
  75 + {{ ' '.join(r[1].split()[n] for n in (0,-1)) }}
  76 + </td>
  77 + <td> <!-- nice -->
  78 + {{ '<i class="far fa-thumbs-up text-success" title="Mais de 75% de respostas correctas"></i>' if r[3] > 0.75 else '' }}
  79 + </td>
  80 + <td> <!-- progress -->
  81 + <div class="progress" style="height: 24px;">
  82 + <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>
  83 + </div>
  84 + </td>
  85 + </tr>
94 {% end %} 86 {% end %}
95 </tbody> 87 </tbody>
96 </table> 88 </table>
97 -<!-- === Change Password Modal =========================================== -->  
98 -  
99 -<div id="password_modal" class="modal fade" tabindex="-1" role="dialog">  
100 - <div class="modal-dialog" role="document">  
101 - <div class="modal-content">  
102 -<!-- header -->  
103 - <div class="modal-header">  
104 - <h5 class="modal-title">Alterar Password</h5>  
105 - </div>  
106 -<!-- body -->  
107 - <div class="modal-body">  
108 - <div class="control-group">  
109 - <label for="new_password" class="control-label">Introduza a nova password:</label>  
110 - <div class="controls">  
111 - <input type="password" id="new_password" name="new_password" autocomplete="new-password">  
112 - </div>  
113 - </div>  
114 - </div>  
115 -<!-- footer -->  
116 - <div class="modal-footer">  
117 - <button type="button" class="btn btn-default" data-dismiss="modal">Cancelar</button>  
118 - <button id="change_password" type="button" class="btn btn-danger" data-dismiss="modal">Alterar</button>  
119 - </div>  
120 -  
121 - </div><!-- /.modal-content -->  
122 - </div><!-- /.modal-dialog -->  
123 -</div>  
124 -  
125 -<!-- /.modal -->  
126 -  
127 </body> 89 </body>
128 </html> 90 </html>
aprendizations/templates/topic.html
1 -<!doctype html>  
2 -<html>  
3 -  
4 -<head>  
5 - <title>{{appname}}</title> 1 +<!DOCTYPE html>
  2 +<html lang="pt-PT">
  3 + <head>
  4 + <meta charset="utf-8" />
  5 + <meta name="viewport" content="width=device-width, initial-scale=1" />
  6 + <meta name="author" content="Miguel Barão" />
6 <link rel="icon" href="/static/favicon.ico"> 7 <link rel="icon" href="/static/favicon.ico">
7 8
8 - <meta charset="utf-8">  
9 - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">  
10 - <meta name="author" content="Miguel Barão"> 9 + <!-- Styles -->
  10 + <!-- <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}"> -->
  11 + <!-- <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}"> -->
  12 +
  13 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  14 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" />
  15 +
  16 + <link rel="stylesheet" href="{{static_url('codemirror/lib/codemirror.css')}}">
  17 + <link rel="stylesheet" href="{{static_url('css/github.css')}}">
  18 + <link rel="stylesheet" href="{{static_url('css/topic.css')}}">
11 19
12 <!-- MathJax3 --> 20 <!-- MathJax3 -->
13 <script> 21 <script>
14 - MathJax = { 22 + MathJax = {
15 tex: { 23 tex: {
16 - inlineMath: [  
17 - ['$$$', '$$$'],  
18 - ['\\(', '\\)']  
19 - ] 24 + inlineMath: [['$$$', '$$$'], ['\\(', '\\)']]
20 }, 25 },
21 svg: { 26 svg: {
22 - fontCache: 'global' 27 + fontCache: 'global'
23 } 28 }
24 - }; 29 + };
25 </script> 30 </script>
26 - <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>  
27 - <!-- Styles -->  
28 - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">  
29 - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">  
30 - <link rel="stylesheet" href="/static/codemirror/lib/codemirror.css">  
31 - <link rel="stylesheet" href="/static/css/animate.min.css">  
32 - <link rel="stylesheet" href="/static/css/github.css">  
33 - <link rel="stylesheet" href="/static/css/topic.css">  
34 <!-- Scripts --> 31 <!-- Scripts -->
35 - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>  
36 - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>  
37 - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>  
38 - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>  
39 - <script defer src="/static/fontawesome-free/js/all.min.js"></script>  
40 - <script defer src="/static/codemirror/lib/codemirror.js"></script>  
41 - <script defer src="/static/js/topic.js"></script>  
42 -</head>  
43 -<!-- ===================================================================== --> 32 + <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
  33 +
  34 + <script defer src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  35 + <script defer src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
  36 +
  37 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  38 + <script defer src="{{static_url('codemirror/lib/codemirror.js')}}"></script>
  39 + <script defer src="{{static_url('js/topic.js')}}"></script>
  40 +
  41 + <title>{{appname}}</title>
  42 + </head>
  43 + <!-- ===================================================================== -->
  44 + <body>
  45 + <!-- Progress bar -->
  46 + <div class="progress fixed-top" style="height: 70px; border-radius: 0px;">
  47 + <div class="progress-bar bg-warning" id="topic_progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 1em;width: 0%"></div>
  48 + </div>
44 49
45 -<body>  
46 <!-- Navbar --> 50 <!-- Navbar -->
47 - <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">  
48 - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">  
49 - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">  
50 - <span class="navbar-toggler-icon"></span> 51 + <nav class="navbar navbar-expand-sm navbar-dark bg-primary fixed-top shadow">
  52 + <div class="container-fluid">
  53 + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora">
  54 + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  55 + <span class="navbar-toggler-icon"></span>
51 </button> 56 </button>
52 - <div class="collapse navbar-collapse" id="navbarText">  
53 - <div class="navbar-nav mr-auto">  
54 - <a class="nav-item nav-link" href="/courses">Cursos</a>  
55 - <a class="nav-item nav-link active" href="/course/{{course_id}}">Tópicos <span class="sr-only">(actual)</span></a>  
56 - <a class="nav-item nav-link" href="/rankings?course={{course_id}}">Classificação</a>  
57 - </div>  
58 - <ul class="navbar-nav">  
59 - <li class="nav-item dropdown">  
60 - <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">  
61 - <i class="fas fa-user-graduate" aria-hidden="true"></i>  
62 - <span id="name">{{ escape(name) }}</span>  
63 - <span class="caret"></span>  
64 - </a>  
65 - <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">  
66 - <a class="dropdown-item" href="/logout">Sair</a>  
67 - </div>  
68 - </li>  
69 - </ul> 57 +
  58 + <div class="collapse navbar-collapse" id="navbarNavText">
  59 + <ul class="navbar-nav">
  60 + <li class="nav-item"><a class="nav-link" href="/courses">Cursos</a></li>
  61 + <li class="nav-item"><a class="nav-link active" aria-current="page" href="/course/{{course_id}}">Tópicos</a></li>
  62 + <!-- <li class="nav-item"><a class="nav-link disabled" href="#">Classificação</a></li> -->
  63 + </ul>
  64 + <ul class="navbar-nav ms-auto">
  65 + <li class="nav-item dropdown">
  66 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  67 + <i class="fas fa-user-graduate" aria-hidden="true"></i>
  68 + &nbsp;
  69 + <span id="name">{{ escape(name) }}</span>
  70 + <span class="caret"></span>
  71 + </a>
  72 + <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
  73 + <li><a class="dropdown-item" href="/logout">Sair</a></li>
  74 + </ul>
  75 + </li>
  76 + </ul>
70 </div> 77 </div>
  78 + </div>
71 </nav> 79 </nav>
72 - <!-- ===================================================================== -->  
73 - <div class="progress">  
74 - <div class="progress-bar bg-warning" id="topic_progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 1em;width: 0%"></div>  
75 - </div> 80 +
76 <!-- ===================================================================== --> 81 <!-- ===================================================================== -->
77 <!-- main panel with questions --> 82 <!-- main panel with questions -->
78 - <div class="container" id="container">  
79 - <div id="notifications"></div>  
80 - <div class="my-5" id="content">  
81 - <form action="/question" method="post" id="question_form" autocomplete="off">  
82 - {% module xsrf_form_html() %}  
83 - <div id="question_div"></div>  
84 - </form>  
85 - <div id="comments"></div>  
86 - </div>  
87 - <div id="wrong" style="display: none">  
88 - <div class="alert alert-danger">  
89 - <h4><i class="fas fa-thumbs-down fa-3x"></i> Não acertou, mas também se aprende com os erros...</h4>  
90 - <div id="solution_wrong"></div>  
91 - </div> 83 + <div class="container" id="container" style="padding-top: 100px;">
  84 +
  85 + <div id="notifications"></div>
  86 +
  87 + <div class="my-5" id="content">
  88 + <form action="/question" method="post" id="question_form" autocomplete="off">
  89 + {% module xsrf_form_html() %}
  90 + <div id="question_div"></div>
  91 + </form>
  92 + <div id="comments"></div>
  93 + </div>
  94 + <!-- feedback right / wrong -->
  95 + <div id="right" style="display: none">
  96 + <div class="alert alert-success shadow">
  97 + <h4><i class="fas fa-thumbs-up fa-3x"></i> Muito bem!</h4>
  98 + <div id="solution_right"></div>
92 </div> 99 </div>
93 - <div id="right" style="display: none">  
94 - <div class="alert alert-success">  
95 - <h4><i class="fas fa-thumbs-up fa-3x"></i> Muito bem!</h4>  
96 - <div id="solution_right"></div>  
97 - </div> 100 + </div>
  101 + <div id="wrong" style="display: none">
  102 + <div class="alert alert-danger shadow">
  103 + <h4><i class="fas fa-thumbs-down fa-3x"></i> Não acertou, mas também se aprende com os erros...</h4>
  104 + <div id="solution_wrong"></div>
98 </div> 105 </div>
99 - <!-- reponder / continuar -->  
100 - <a class="btn btn-primary btn-lg btn-block my-5" id="submit" data-toggle="tooltip" data-placement="right" href="#solution"></a>  
101 - <!-- title="Shift-Enter" --> 106 + </div>
  107 + <!-- button reponder / continuar -->
  108 + <div class="d-grid gap-2">
  109 + <button type="submit" class="btn btn-primary btn-lg btn-block my-5 shadow bg-gradient" id="submit" data-bs-toggle="button" href="#solution" style="display: none"></button>
  110 + </div>
102 </div> 111 </div>
103 -</body>  
104 -  
105 -</html>  
106 \ No newline at end of file 112 \ No newline at end of file
  113 + </body>
  114 +</html>
aprendizations/tools.py
@@ -136,7 +136,7 @@ markdown = MarkdownWithMath(HighlightRenderer(escape=True)) @@ -136,7 +136,7 @@ markdown = MarkdownWithMath(HighlightRenderer(escape=True))
136 136
137 def md_to_html(text: str, strip_p_tag: bool = False) -> str: 137 def md_to_html(text: str, strip_p_tag: bool = False) -> str:
138 md: str = markdown(text) 138 md: str = markdown(text)
139 - if strip_p_tag and md.startswith('<p>') and md.endswith('</p>'): 139 + if strip_p_tag and md.startswith('<p>') and md.endswith('</p>\n'):
140 return md[3:-5] 140 return md[3:-5]
141 else: 141 else:
142 return md 142 return md
@@ -228,7 +228,7 @@ async def run_script_async(script: str, @@ -228,7 +228,7 @@ async def run_script_async(script: str,
228 ) 228 )
229 229
230 try: 230 try:
231 - stdout, stderr = await asyncio.wait_for( 231 + stdout, _ = await asyncio.wait_for(
232 p.communicate(input=stdin.encode('utf-8')), 232 p.communicate(input=stdin.encode('utf-8')),
233 timeout=timeout 233 timeout=timeout
234 ) 234 )
demo/math/multiplication/multiplication-table.py
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
3 import random 3 import random
4 -import sys  
5 4
6 -# can be repeated  
7 -# x = random.randint(2, 9)  
8 -# y = random.randint(2, 9)  
9 -  
10 -# with x != y  
11 x, y = random.sample(range(2,10), k=2) 5 x, y = random.sample(range(2,10), k=2)
12 r = x * y 6 r = x * y
13 7
@@ -19,6 +13,7 @@ type: text @@ -19,6 +13,7 @@ type: text
19 title: Multiplicação (tabuada) 13 title: Multiplicação (tabuada)
20 text: | 14 text: |
21 Qual o resultado da multiplicação ${x}\\times {y}$? 15 Qual o resultado da multiplicação ${x}\\times {y}$?
  16 +transform: ['trim']
22 correct: ['{r}'] 17 correct: ['{r}']
23 solution: | 18 solution: |
24 A multiplicação é a repetição da soma. Podemos fazer de duas maneiras: 19 A multiplicação é a repetição da soma. Podemos fazer de duas maneiras:
demo/math/multiplication/questions.yaml
1 --- 1 ---
2 -# --------------------------------------------------------------------------- 2 +# ----------------------------------------------------------------------------
3 - type: generator 3 - type: generator
4 ref: multiplication-table 4 ref: multiplication-table
5 script: multiplication-table.py 5 script: multiplication-table.py
6 6
  7 +# ----------------------------------------------------------------------------
7 - type: checkbox 8 - type: checkbox
8 ref: multiplication-properties 9 ref: multiplication-properties
9 title: Propriedades da multiplicação 10 title: Propriedades da multiplicação
@@ -17,7 +18,7 @@ @@ -17,7 +18,7 @@
17 # wrong 18 # wrong
18 - Existência de inverso, todos os números $x$ tem um inverso $1/x$ tal que 19 - Existência de inverso, todos os números $x$ tem um inverso $1/x$ tal que
19 $x(1/x)=1$. 20 $x(1/x)=1$.
20 - correct: [1, 1, 1, 1, -1] 21 + correct: [1, 1, 1, 1, 0]
21 solution: | 22 solution: |
22 Na multiplicação nem todos os números têm inverso. Só têm inverso os números 23 Na multiplicação nem todos os números têm inverso. Só têm inverso os números
23 diferentes de zero. 24 diferentes de zero.
1 [mypy] 1 [mypy]
2 -python_version = 3.7  
3 -warn_return_any = True  
4 -warn_unused_configs = True  
5 -  
6 -[mypy-sqlalchemy.*]  
7 -ignore_missing_imports = True 2 +python_version = 3.9
  3 +plugins = sqlalchemy.ext.mypy.plugin
8 4
9 [mypy-pygments.*] 5 [mypy-pygments.*]
10 ignore_missing_imports = True 6 ignore_missing_imports = True
package-lock.json
1 { 1 {
  2 + "name": "aprendizations",
  3 + "lockfileVersion": 2,
2 "requires": true, 4 "requires": true,
3 - "lockfileVersion": 1, 5 + "packages": {
  6 + "": {
  7 + "dependencies": {
  8 + "@fortawesome/fontawesome-free": "^5.15.3",
  9 + "codemirror": "^5.59.4"
  10 + }
  11 + },
  12 + "node_modules/@fortawesome/fontawesome-free": {
  13 + "version": "5.15.4",
  14 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
  15 + "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==",
  16 + "hasInstallScript": true,
  17 + "engines": {
  18 + "node": ">=6"
  19 + }
  20 + },
  21 + "node_modules/codemirror": {
  22 + "version": "5.62.2",
  23 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.62.2.tgz",
  24 + "integrity": "sha512-tVFMUa4J3Q8JUd1KL9yQzQB0/BJt7ZYZujZmTPgo/54Lpuq3ez4C8x/ATUY/wv7b7X3AUq8o3Xd+2C5ZrCGWHw=="
  25 + }
  26 + },
4 "dependencies": { 27 "dependencies": {
5 "@fortawesome/fontawesome-free": { 28 "@fortawesome/fontawesome-free": {
6 - "version": "5.12.0",  
7 - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.12.0.tgz",  
8 - "integrity": "sha512-vKDJUuE2GAdBERaQWmmtsciAMzjwNrROXA5KTGSZvayAsmuTGjam5z6QNqNPCwDfVljLWuov1nEC3mEQf/n6fQ==" 29 + "version": "5.15.4",
  30 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
  31 + "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg=="
9 }, 32 },
10 "codemirror": { 33 "codemirror": {
11 - "version": "5.51.0",  
12 - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.51.0.tgz",  
13 - "integrity": "sha512-vyuYYRv3eXL0SCuZA4spRFlKNzQAewHcipRQCOKgRy7VNAvZxTKzbItdbCl4S5AgPZ5g3WkHp+ibWQwv9TLG7Q=="  
14 - },  
15 - "mdbootstrap": {  
16 - "version": "4.12.0",  
17 - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.12.0.tgz",  
18 - "integrity": "sha512-+X4x63tE96zpVOcRlVUGdcR65M9Ud+/l1TvdmcwUjEGo3ktn9TO3e6S3DBLTvchO9U5eKuJh/MIWIGac7+569g==" 34 + "version": "5.62.2",
  35 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.62.2.tgz",
  36 + "integrity": "sha512-tVFMUa4J3Q8JUd1KL9yQzQB0/BJt7ZYZujZmTPgo/54Lpuq3ez4C8x/ATUY/wv7b7X3AUq8o3Xd+2C5ZrCGWHw=="
19 } 37 }
20 } 38 }
21 } 39 }
@@ -2,9 +2,8 @@ @@ -2,9 +2,8 @@
2 "description": "Javascript libraries required to run the server", 2 "description": "Javascript libraries required to run the server",
3 "email": "mjsb@uevora.pt", 3 "email": "mjsb@uevora.pt",
4 "dependencies": { 4 "dependencies": {
5 - "@fortawesome/fontawesome-free": "^5.12.0",  
6 - "codemirror": "^5.51.0",  
7 - "mdbootstrap": "^4.12.0" 5 + "@fortawesome/fontawesome-free": "^5.15.3",
  6 + "codemirror": "^5.59.4"
8 }, 7 },
9 "private": true 8 "private": true
10 } 9 }
@@ -18,13 +18,13 @@ setup( @@ -18,13 +18,13 @@ setup(
18 url="https://git.xdi.uevora.pt/mjsb/aprendizations.git", 18 url="https://git.xdi.uevora.pt/mjsb/aprendizations.git",
19 packages=find_packages(), 19 packages=find_packages(),
20 include_package_data=True, # install files from MANIFEST.in 20 include_package_data=True, # install files from MANIFEST.in
21 - python_requires='>=3.7.*', 21 + python_requires='>=3.8.*',
22 install_requires=[ 22 install_requires=[
23 'tornado>=6.0', 23 'tornado>=6.0',
24 'mistune', 24 'mistune',
25 'pyyaml>=5.1', 25 'pyyaml>=5.1',
26 'pygments', 26 'pygments',
27 - 'sqlalchemy', 27 + 'sqlalchemy>=1.4',
28 'bcrypt>=3.1', 28 'bcrypt>=3.1',
29 'networkx>=2.4' 29 'networkx>=2.4'
30 ], 30 ],