Commit dda945f12bc5e16ad49f0c972ab33862f8e9ed7f

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

fixed most errors reported by pyright and mypy

other changes and cleanup
1 1
2 # BUGS 2 # BUGS
3 3
4 -- nao esta a seguir o max_tries definido no ficheiro de dependencias. 4 +- internal server error ao fazer logout no macos python3.8
  5 +- GET can get filtered by browser cache
  6 +- topicos chapter devem ser automaticamente completos assim que as dependencias
  7 + são satisfeitas. Nao devia ser necessario (ou possivel?) clicar neles.
  8 +- topicos do tipo learn deviam por defeito nao ser randomizados e assumir
  9 + ficheiros `learn.yaml`.
  10 +- internal server error 500... experimentar cenario: aluno tem login efectuado,
  11 + prof muda pw e faz login/logout. aluno obtem erro 500.
  12 +- radio sem options rebenta com aprendizations --check
  13 +- chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos
  14 + pensam que já terminaram e não conseguem progredir por causa das
  15 + dependencias.
  16 +- if topic deps on invalid ref terminates server with "Unknown error".
  17 +- warning nos topics que não são usados em nenhum curso
  18 +- nao esta a seguir o `max_tries` definido no ficheiro de dependencias.
5 - devia mostrar timeout para o aluno saber a razao. 19 - devia mostrar timeout para o aluno saber a razao.
6 - permitir configuracao para escolher entre static files locais ou remotos 20 - permitir configuracao para escolher entre static files locais ou remotos
7 - shift-enter não está a funcionar 21 - shift-enter não está a funcionar
@@ -9,67 +23,93 @@ @@ -9,67 +23,93 @@
9 23
10 # TODO 24 # TODO
11 25
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. 26 +- shuffle das perguntas dentro de um topico
  27 +- alterar tabelas para incluir email de recuperacao de password (e outros
  28 + avisos)
  29 +- registar `last_seen` e remover os antigos de cada vez que houver um login.
14 - indicar qtos topicos faltam (>=50%) para terminar o curso. 30 - indicar qtos topicos faltam (>=50%) para terminar o curso.
15 - ao fim de 3 tentativas com password errada, envia email com nova password. 31 - 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. 32 +- mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo
  33 + expande as dependencias.
  34 +- mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado
  35 + topicos.
18 - botão não sei... 36 - botão não sei...
19 - mostrar icon "loading..." enquanto está a corrigir uma pergunta. 37 - mostrar icon "loading..." enquanto está a corrigir uma pergunta.
20 - session management. close after inactive time. 38 - session management. close after inactive time.
21 - radio e checkboxes, aceitar numeros como seleccao das opcoes. 39 - 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/ 40 +- reload das perguntas enquanto online. ver signal em
  41 + [](http://stackabuse.com/python-async-await-tutorial/)
23 - tabela de progresso de todos os alunos por topico. 42 - tabela de progresso de todos os alunos por topico.
24 - tabela com perguntas / quantidade de respostas certas/erradas. 43 - tabela com perguntas / quantidade de respostas certas/erradas.
25 - tabela com topicos / quantidade de estrelas. 44 - tabela com topicos / quantidade de estrelas.
26 - pymips: activar/desactivar instruções 45 - pymips: activar/desactivar instruções
27 - titulos das perguntas não suportam markdown. 46 - 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. 47 +- pagina report que permita ver tabela alunos/topicos, estatisticas perguntas
  48 + mais falhadas, tempo médio por pergunta.
29 - normalizar com perguntations. 49 - normalizar com perguntations.
30 50
31 # FIXED 51 # FIXED
32 52
33 -- templates question-*.html tem input hidden question_ref que não é usado. remover? 53 +- templates question-*.html tem input hidden question_ref que não é usado.
  54 + remover?
34 - goals se forem do tipo chapter deve importar todas as dependencias do chapter. 55 - 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. 56 +- initdb da integrity error se no mesmo comando existirem alunos repetidos
  57 + (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
  58 +- dependencias que não são goals de um curso, só devem aparecer se ainda não
  59 + tiverem sido feitas.
37 - ir para inicio da pagina quando le nova pergunta. 60 - ir para inicio da pagina quando le nova pergunta.
38 - CRITICAL nao esta a guardar o progresso na base de dados. 61 - CRITICAL nao esta a guardar o progresso na base de dados.
39 - mesma ref no mesmo ficheiro não é detectado. 62 - mesma ref no mesmo ficheiro não é detectado.
40 - enter nas respostas mostra json 63 - enter nas respostas mostra json
41 -- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande) 64 +- apos clicar no botao responder, inactivar o input (importante quando o tempo
  65 + de correcção é grande)
42 - double click submits twice. 66 - 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. 67 +- checkbox devia ter correct no intervalo [0,1] tal como radio. em caso de
  68 + desconto a correccção faz 2*x-1. isto permite a mesma semantica nos dois
  69 + tipos de perguntas.
44 - marking all options right in a radio question breaks! 70 - marking all options right in a radio question breaks!
45 - implementar servidor http com redirect para https. 71 - implementar servidor http com redirect para https.
46 - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question. 72 - 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. 73 - 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. 74 +- mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam
  75 + centradas em toda a coluna mas apenas na largura do parágrafo.
  76 +- QFactory.generate() devia fazer run da `gen_async,` ou remover.
50 - classificacoes so devia mostrar os que ja fizeram alguma coisa 77 - classificacoes so devia mostrar os que ja fizeram alguma coisa
51 - impedir que quando students.db não é encontrado, crie um ficheiro vazio. 78 - 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. 79 +- permite definir goal, mas nao verifica se esta no grafo. rebenta no
  80 + `start_topic`.
  81 +- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as
  82 + imagengs porque o `current_topic` passa a None antes de carregar no botao
  83 + continuar. O caminho é prefix+None e dá erro.
54 - caixas com os cursos não se ajustam bem com ecran estreito. 84 - 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. 85 +- obter rankings por curso `GET course=course_id`
  86 +- no curso de linear algebra, as perguntas estao shuffled, mas nao deviam
  87 + estar... nao esta a obedecer a keyword shuffle.
57 - menu nao mostra as opcoes correctamente 88 - menu nao mostra as opcoes correctamente
58 - finish topic vai para a lista de cursos. devia ficar no mesmo curso. 89 - finish topic vai para a lista de cursos. devia ficar no mesmo curso.
59 - mathjax nao esta a correr sobre o titulo. 90 - mathjax nao esta a correr sobre o titulo.
60 - forgetting factor is hardcoded in student.py 91 - forgetting factor is hardcoded in student.py
61 - add aprendizatons --version 92 - 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 /. 93 +- se aluno abre dois tabs no browser, conseque navegar em simultaneo para
  94 + perguntas diferentes. quando submete uma delas dá asneira. Tem de haver um
  95 + campo hidden que tenha um céodigo único que indique qual a pergunta. do lado
  96 + do servidor apnas há o codigo da pergunta corrente, se forem diferentes faz
  97 + redirect para /.
63 - nos topicos learn.yaml, qd falha acrescenta no fim. nao faz sentido. 98 - 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 99 +- não esta a fazer render correcto de tabelas nas opcoes checkbox. e.g.
  100 + `information-theory/source-coding-theory/block-codes`
65 - max tries nas perguntas. 101 - max tries nas perguntas.
66 - mostrar feedback/solucoes quando acerta, ou excede max tries. 102 - 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. 103 +- quando se pressiona "responde" rapido (enquanto a animacao dura), a pergunta
  104 + passa para a seguinte sem haver o correspondente redraw, ou seja a proxima
  105 + resposta nao é a da pergunta mostrada.
68 - botao para mostrar a solução quando se acerta. 106 - botao para mostrar a solução quando se acerta.
69 - não está a guardar o resultado no final do topico 107 - 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... 108 +- esta a permitir 2 logins em simultaneo do mesmo user. fica tudo baralhado se
  109 + mxerem em simultaneo...
71 - errar no ultimo topico nao mostra solucao? 110 - 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. 111 +- quando a pergunta devolve comments, este é apresentado, mas fica persistente
  112 + nas tentativas seguintes. devia ser limpo apos a segunda submissao.
73 - na definicao dos topicos, indicar: 113 - na definicao dos topicos, indicar:
74 "file: questions.yaml" (default questions.yaml) 114 "file: questions.yaml" (default questions.yaml)
75 "shuffle: True/False" (default False) 115 "shuffle: True/False" (default False)
@@ -84,9 +124,11 @@ @@ -84,9 +124,11 @@
84 - each topic only loads a sample of K questions (max) in random order. 124 - each topic only loads a sample of K questions (max) in random order.
85 - change password modal nao aparece no ipad (safari e firefox) 125 - change password modal nao aparece no ipad (safari e firefox)
86 - detect questions in questions.yaml without ref -> error ou generate default. 126 - 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. 127 +- generators e correct scripts que durem muito tempo bloqueiam o eventloop do
  128 + tornado.
88 - servir imagens/ficheiros. 129 - servir imagens/ficheiros.
89 -- radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma selecção aleatoria destas (so com 1 certa). 130 +- radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma
  131 + selecção aleatoria destas (so com 1 certa).
90 - checkbox: cada opção pode ser uma dupla (certo, errado) sendo escolhida uma aleatória. 132 - checkbox: cada opção pode ser uma dupla (certo, errado) sendo escolhida uma aleatória.
91 - async/threadpool no bcrypt do initdb. 133 - async/threadpool no bcrypt do initdb.
92 - numero de estrelas depende da proporcao entre certas e erradas. 134 - numero de estrelas depende da proporcao entre certas e erradas.
@@ -97,10 +139,12 @@ @@ -97,10 +139,12 @@
97 - remover learn.css uma vez que nao é usado em lado nenhum? 139 - remover learn.css uma vez que nao é usado em lado nenhum?
98 - check if user already logged in 140 - check if user already logged in
99 - mover javascript para ficheiros externos e carregar com script defer src 141 - 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. 142 +- implementar xsrf. Ver [](http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection)
  143 +- se refs de um topic estao invalidos, nao carrega esse topico. devia haver um
  144 + error nos logs a indicar qual o ref invalido.
102 - link directo para topico nao valida se topico esta unlocked. 145 - link directo para topico nao valida se topico esta unlocked.
103 -- templates not working: quesntion-information, question-warning (remove all informative panels??) 146 +- templates not working: quesntion-information, question-warning (remove all
  147 + informative panels??)
104 - enderecos errados dao internal error. 148 - enderecos errados dao internal error.
105 - barra de progresso nao está visível. 149 - barra de progresso nao está visível.
106 - tabs em textarea nao funcionam correctamente (insere 1 espaco em vez de 4) 150 - tabs em textarea nao funcionam correctamente (insere 1 espaco em vez de 4)
@@ -110,14 +154,15 @@ @@ -110,14 +154,15 @@
110 - animação no final de cada topico para se perceber a transição 154 - animação no final de cada topico para se perceber a transição
111 - "<" is not escaped in markdown. 155 - "<" is not escaped in markdown.
112 - Está a mostrar a solução em 'comments'!!! 156 - Está a mostrar a solução em 'comments'!!!
113 -- database: answers não tem referencia para o topico, so para question_ref 157 +- database: answers não tem referencia para o topico, so para `question_ref`
114 - melhorar markdown das tabelas. 158 - melhorar markdown das tabelas.
115 - gravar evolucao na bd no final de cada topico. 159 - gravar evolucao na bd no final de cada topico.
116 - submeter questoes radio, da erro se nao escolher nenhuma opção. 160 - submeter questoes radio, da erro se nao escolher nenhuma opção.
117 - indentação da primeira linha de código não funciona. 161 - indentação da primeira linha de código não funciona.
118 - markdown com o mistune. 162 - markdown com o mistune.
119 - change password in maintopics.html, falta menu para lançar modal 163 - 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 164 +- ver documentacao de migracao para networkx 2.0
  165 + [](https://networkx.github.io/documentation/stable/release/migration_guide_from_1.x_to_2.0.html)
121 - script para adicionar users/reset passwords. 166 - script para adicionar users/reset passwords.
122 - os topicos locked devem estar inactivos no sidebar. 167 - os topicos locked devem estar inactivos no sidebar.
123 - enter faz GET /question, que responde com json no ecran. (solution: disabled enter) 168 - enter faz GET /question, que responde com json no ecran. (solution: disabled enter)
@@ -126,7 +171,8 @@ @@ -126,7 +171,8 @@
126 - indicar o topico actual no sidebar 171 - indicar o topico actual no sidebar
127 - reload da página rebenta o estado. 172 - reload da página rebenta o estado.
128 - text deve mostrar no html os valores iniciais de ans, se existir 173 - 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. 174 +- nao permite perguntas repetidas. iterar questions da configuracao em vez das
  175 + do ficheiro. ver app.py linha 223.
130 - level depender do numero de respostas correctas 176 - level depender do numero de respostas correctas
131 - pymips a funcionar 177 - pymips a funcionar
132 - logs mostram que está a gerar cada pergunta 2 vezes...?? 178 - logs mostram que está a gerar cada pergunta 2 vezes...??
@@ -138,15 +184,18 @@ @@ -138,15 +184,18 @@
138 - se students.db não existe, rebenta. 184 - se students.db não existe, rebenta.
139 - não entra à primeira 185 - não entra à primeira
140 - configuração e linha de comando. 186 - 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. 187 +- o browser é redireccionado para /question em vez de fazer um post?? quando se
  188 + pressiona enter numa caixa text edit.
142 - load/save the knowledge state of the student 189 - load/save the knowledge state of the student
143 - servir ficheiros de public temporariamente 190 - servir ficheiros de public temporariamente
144 - path dos generators scripts mal construido 191 - path dos generators scripts mal construido
145 - questions hardcoded in LearnApp. 192 - questions hardcoded in LearnApp.
146 - Factory para cada pergunta individual em vez de pool 193 - Factory para cada pergunta individual em vez de pool
147 -- implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona, enter submete. 194 +- implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona,
  195 + enter submete.
148 - logging 196 - logging
149 -- textarea tem codigo para preencher o texto, mas ja não é necessário porque pergunta não é reloaded. 197 +- textarea tem codigo para preencher o texto, mas ja não é necessário porque
  198 + pergunta não é reloaded.
150 - gravar answers -> db 199 - gravar answers -> db
151 - como gerar key para secure cookie. 200 - como gerar key para secure cookie.
152 - https. certificados selfsigned, no-ip nao suporta certificados 201 - https. certificados selfsigned, no-ip nao suporta certificados
@@ -161,4 +210,5 @@ @@ -161,4 +210,5 @@
161 - clicar texto selecciona checkboxes/radio. 210 - clicar texto selecciona checkboxes/radio.
162 - focar text/textarea 211 - focar text/textarea
163 - implementar template base das perguntas base e estender para cada tipo. 212 - 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. 213 +- submissão com enter em perguntas text faz get? provavelmente está a fazer o
  214 + submit do form em vez de ir pelo ajax.
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
@@ -33,7 +33,7 @@ sudo port install python37 npm6 # MacOS @@ -33,7 +33,7 @@ sudo port install python37 npm6 # MacOS
33 ``` 33 ```
34 34
35 #### Installing from source 35 #### Installing from source
36 - 36 +
37 Make sure that the build tools and libraries are installed: 37 Make sure that the build tools and libraries are installed:
38 38
39 ```sh 39 ```sh
@@ -54,7 +54,7 @@ This will install python locally under `~/.local/bin`. Make sure to add it to yo @@ -54,7 +54,7 @@ This will install python locally under `~/.local/bin`. Make sure to add it to yo
54 54
55 ### Install pip 55 ### Install pip
56 56
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. 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: 58 To install `pip` from the system package manager:
59 59
60 ```sh 60 ```sh
@@ -70,12 +70,12 @@ python3.7 -m pip install pip # install in user area @@ -70,12 +70,12 @@ python3.7 -m pip install pip # install in user area
70 ``` 70 ```
71 71
72 The latter will install `pip` in your user account under `~/.local/bin`. 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 73 +In the end you should be able to run `pip --version` and
74 `python3 -c "import sqlite3"` without errors. 74 `python3 -c "import sqlite3"` without errors.
75 -Sometimes the `pip` command is named `pip3`, 75 +Sometimes the `pip` command is named `pip3`,
76 `pip3.7` or `pip-3.7`. 76 `pip3.7` or `pip-3.7`.
77 77
78 -Edit the configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or 78 +Edit the configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or
79 `Library/Application Support/pip/pip.conf` (MacOS) and add the lines 79 `Library/Application Support/pip/pip.conf` (MacOS) and add the lines
80 80
81 ```ini 81 ```ini
@@ -121,7 +121,7 @@ aprendizations --help @@ -121,7 +121,7 @@ aprendizations --help
121 121
122 We need certificates for https. Certificates can be self-signed or validated by a trusted authority. 122 We need certificates for https. Certificates can be self-signed or validated by a trusted authority.
123 123
124 -Self-signed can be used locally for development and testing, but browsers will 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. 125 complain. LetsEncrypt issues trusted and free certificates, but the server must have a registered publicly accessible domain name.
126 126
127 #### Generating selfsigned certificates 127 #### Generating selfsigned certificates
@@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD @@ -141,7 +141,7 @@ sudo pkg install py36-certbot # FreeBSD
141 sudo apt install certbot # Ubuntu 141 sudo apt install certbot # Ubuntu
142 ``` 142 ```
143 143
144 -To generate or renew the certificates, ports 80 and 443 have to be accessible. The firewall and webserver have to be stopped. 144 +To generate or renew the certificates, ports 80 and 443 have to be accessible. **The firewall and webserver have to be stopped**.
145 145
146 ```sh 146 ```sh
147 sudo certbot certonly --standalone -d www.example.com # first time 147 sudo certbot certonly --standalone -d www.example.com # first time
@@ -151,6 +151,7 @@ sudo certbot renew # renew @@ -151,6 +151,7 @@ sudo certbot renew # renew
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: 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:
152 152
153 ```sh 153 ```sh
  154 +cd ~/.local/share/certs
154 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem . 155 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 . 156 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem .
156 chmod 400 cert.pem privkey.pem 157 chmod 400 cert.pem privkey.pem
@@ -162,7 +163,7 @@ chmod 400 cert.pem privkey.pem @@ -162,7 +163,7 @@ chmod 400 cert.pem privkey.pem
162 ### Database 163 ### Database
163 164
164 User data is maintained in a sqlite3 database which has to be created manually using the `initdb-aprendizations` command. 165 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 +The database file should be located in the same directory as the main
166 YAML configuration file. 167 YAML configuration file.
167 168
168 For example, to run the included demo do: 169 For example, to run the included demo do:
@@ -189,16 +190,16 @@ cd demo @@ -189,16 +190,16 @@ cd demo
189 aprendizations demo.yaml 190 aprendizations demo.yaml
190 ``` 191 ```
191 192
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 193 +Open the browser at [https://127.0.0.1:8443](https://127.0.0.1:8443).
  194 +If everything looks good, check at the correct address
194 `https://www.example.com:8443`. 195 `https://www.example.com:8443`.
195 -The option `--debug` provides more verbose logging and might 196 +The option `--debug` provides more verbose logging and might
196 be useful during testing. 197 be useful during testing.
197 198
198 ### Firewall configuration 199 ### Firewall configuration
199 200
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. 201 +Ports 80 and 443 are only usable by root. For security reasons the server runs as an unprivileged user on port 8443 for https.
  202 +To access the server in the default https port (443), port forwarding can be configured in the firewall.
202 203
203 #### FreeBSD and pf 204 #### FreeBSD and pf
204 205
@@ -253,20 +254,20 @@ pip install -U . # updates installed version to latest @@ -253,20 +254,20 @@ pip install -U . # updates installed version to latest
253 254
254 ## Troubleshooting 255 ## Troubleshooting
255 256
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 257 +To help with troubleshooting, use the option `--debug` when running the server.
  258 +This will increase logs in the terminal and will present the python exception
258 errors in the browser. 259 errors in the browser.
259 260
260 -Logging levels can be adjusted in `~/.config/aprendizations/logger.yaml` and 261 +Logging levels can be adjusted in `~/.config/aprendizations/logger.yaml` and
261 `~/.config/aprendizations/logger-debug.yaml`. 262 `~/.config/aprendizations/logger-debug.yaml`.
262 263
263 If these files do not yet exist, there are examples in `aprendizations/config` that can be copied to `~/.config/aprendizations`. 264 If these files do not yet exist, there are examples in `aprendizations/config` that can be copied to `~/.config/aprendizations`.
264 265
265 #### UnicodeEncodeError 266 #### UnicodeEncodeError
266 267
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 268 +The server should not generate this error, but when using external scripts to
  269 +generate questions or to correct, these scripts can print unicode strings to
  270 +stdout. If the terminal does not support unicode, python will generate this
270 exception. 271 exception.
271 272
272 - FreeBSD fix: edit `~/.login_conf` to use UTF-8, for example: 273 - FreeBSD fix: edit `~/.login_conf` to use UTF-8, for example:
@@ -279,6 +280,22 @@ me:\ @@ -279,6 +280,22 @@ me:\
279 280
280 - Debian fix: check `locale`... 281 - Debian fix: check `locale`...
281 282
  283 +
  284 +#### The application runs but questions do not show up
  285 +
  286 +Some operating systems have an option to disable animations to try to avoid
  287 +motion sickness in some people. Browsers will check this option with the OS and
  288 +prevent animate.css library to work. Since questions have several animations,
  289 +these will will not work and nothing is shown on the page.
  290 +
  291 +To fix this issue you need to allow animations in the Operating System:
  292 +
  293 +- On windows 10, go to System Preferences, search for "Show animations in
  294 + windows" and turn it **ON**.
  295 +- On MacOS or iOS search for reduced motion and switch it **OFF**
  296 + (Preferences -> Acessibility -> Display -> Reduce motion).
  297 +
  298 +
282 ## FAQ 299 ## FAQ
283 300
284 Common database manipulations: 301 Common database manipulations:
@@ -300,4 +317,3 @@ sqlite3 students.db &quot;select student_id, count(topic_id) from studenttopic group @@ -300,4 +317,3 @@ sqlite3 students.db &quot;select student_id, count(topic_id) from studenttopic group
300 # Which questions have more wrong answers? 317 # 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" 318 sqlite3 students.db "select count(ref), ref from answers where grade<1.0 group by ref order by count(ref) desc"
302 ``` 319 ```
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.07.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
@@ -10,14 +14,18 @@ from concurrent.futures import ThreadPoolExecutor @@ -10,14 +14,18 @@ from concurrent.futures import ThreadPoolExecutor
10 # third party libraries 14 # third party libraries
11 import bcrypt 15 import bcrypt
12 import sqlalchemy as sa 16 import sqlalchemy as sa
  17 +import sqlalchemy.orm as orm
  18 +from sqlalchemy.exc import IntegrityError
13 19
14 # this project 20 # this project
15 -from .models import Base, Student 21 +from aprendizations.models import Base, Student
16 22
17 23
18 # =========================================================================== 24 # ===========================================================================
19 # Parse command line options 25 # Parse command line options
20 def parse_commandline_arguments(): 26 def parse_commandline_arguments():
  27 + '''Parse command line arguments'''
  28 +
21 argparser = argparse.ArgumentParser( 29 argparser = argparse.ArgumentParser(
22 formatter_class=argparse.ArgumentDefaultsHelpFormatter, 30 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
23 description='Insert new users into a database. Users can be imported ' 31 description='Insert new users into a database. Users can be imported '
@@ -65,9 +73,12 @@ def parse_commandline_arguments(): @@ -65,9 +73,12 @@ def parse_commandline_arguments():
65 73
66 74
67 # =========================================================================== 75 # ===========================================================================
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): 76 def get_students_from_csv(filename):
  77 + '''Reads CSV file with enrolled students in SIIUE format.
  78 + SIIUE names can have suffixes like "(TE)" and are sometimes capitalized.
  79 + These suffixes are removed.'''
  80 +
  81 + # SIIUE format for CSV files
71 csv_settings = { 82 csv_settings = {
72 'delimiter': ';', 83 'delimiter': ';',
73 'quotechar': '"', 84 'quotechar': '"',
@@ -75,8 +86,8 @@ def get_students_from_csv(filename): @@ -75,8 +86,8 @@ def get_students_from_csv(filename):
75 } 86 }
76 87
77 try: 88 try:
78 - with open(filename, encoding='iso-8859-1') as f:  
79 - csvreader = csv.DictReader(f, **csv_settings) 89 + with open(filename, encoding='iso-8859-1') as file:
  90 + csvreader = csv.DictReader(file, **csv_settings)
80 students = [{ 91 students = [{
81 'uid': s['N.º'], 92 'uid': s['N.º'],
82 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) 93 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
@@ -92,52 +103,51 @@ def get_students_from_csv(filename): @@ -92,52 +103,51 @@ def get_students_from_csv(filename):
92 103
93 104
94 # =========================================================================== 105 # ===========================================================================
95 -# replace password by hash for a single student  
96 -def hashpw(student, pw=None): 106 +def hashpw(student, passw=None):
  107 + '''replace password by hash for a single student'''
97 print('.', end='', flush=True) 108 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()) 109 + passw = (passw or student.get('pw', None) or student['uid']).encode('utf-8')
  110 + student['pw'] = bcrypt.hashpw(passw, bcrypt.gensalt())
100 111
101 112
102 # =========================================================================== 113 # ===========================================================================
103 def show_students_in_database(session, verbose=False): 114 def show_students_in_database(session, verbose=False):
104 - try:  
105 - users = session.query(Student).all()  
106 - except Exception:  
107 - raise 115 + '''print students that are in the database'''
  116 + users = session.query(Student).all()
  117 + total = len(users)
  118 +
  119 + print('\nRegistered users:')
  120 + if total == 0:
  121 + print(' -- none --')
108 else: 122 else:
109 - n = len(users)  
110 - print(f'\nRegistered users:')  
111 - if n == 0:  
112 - print(' -- none --') 123 + users.sort(key=lambda u: f'{u.id:>12}') # sort by number
  124 + if verbose:
  125 + for user in users:
  126 + print(f'{user.id:>12} {user.name}')
113 else: 127 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}.') 128 + print(f'{users[0].id:>12} {users[0].name}')
  129 + if total > 1:
  130 + print(f'{users[1].id:>12} {users[1].name}')
  131 + if total > 3:
  132 + print(' | |')
  133 + if total > 2:
  134 + print(f'{users[-1].id:>12} {users[-1].name}')
  135 + print(f'Total: {total}.')
127 136
128 137
129 # =========================================================================== 138 # ===========================================================================
130 def main(): 139 def main():
  140 + '''performs the main functions'''
  141 +
131 args = parse_commandline_arguments() 142 args = parse_commandline_arguments()
132 143
133 # --- database stuff 144 # --- database stuff
134 - print(f'Using database: ', args.db) 145 + print(f'Using database: {args.db}')
135 engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) 146 engine = sa.create_engine(f'sqlite:///{args.db}', echo=False)
136 Base.metadata.create_all(engine) # Creates schema if needed 147 Base.metadata.create_all(engine) # Creates schema if needed
137 - Session = sa.orm.sessionmaker(bind=engine)  
138 - session = Session() 148 + session = orm.sessionmaker(bind=engine)()
139 149
140 - # --- make list of students to insert/update 150 + # --- build list of students to insert/update
141 students = [] 151 students = []
142 152
143 for csvfile in args.csvfile: 153 for csvfile in args.csvfile:
@@ -159,13 +169,13 @@ def main(): @@ -159,13 +169,13 @@ def main():
159 169
160 if new_students: 170 if new_students:
161 # --- password hashing 171 # --- password hashing
162 - print(f'Generating password hashes', end='') 172 + print('Generating password hashes', end='')
163 with ThreadPoolExecutor() as executor: 173 with ThreadPoolExecutor() as executor:
164 executor.map(lambda s: hashpw(s, args.pw), new_students) 174 executor.map(lambda s: hashpw(s, args.pw), new_students)
165 175
166 print('\nAdding students:') 176 print('\nAdding students:')
167 - for s in new_students:  
168 - print(f' + {s["uid"]}, {s["name"]}') 177 + for student in new_students:
  178 + print(f' + {student["uid"]}, {student["name"]}')
169 179
170 try: 180 try:
171 session.add_all([Student(id=s['uid'], 181 session.add_all([Student(id=s['uid'],
@@ -173,7 +183,7 @@ def main(): @@ -173,7 +183,7 @@ def main():
173 password=s['pw']) 183 password=s['pw'])
174 for s in new_students]) 184 for s in new_students])
175 session.commit() 185 session.commit()
176 - except sa.exc.IntegrityError: 186 + except IntegrityError:
177 print('!!! Integrity error. Aborted !!!\n') 187 print('!!! Integrity error. Aborted !!!\n')
178 session.rollback() 188 session.rollback()
179 189
@@ -182,15 +192,15 @@ def main(): @@ -182,15 +192,15 @@ def main():
182 print('There are no new students to add.') 192 print('There are no new students to add.')
183 193
184 # --- update data for student in the database 194 # --- 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()) 195 + for student_id in args.update:
  196 + print(f'Updating password of: {student_id}')
  197 + student = session.query(Student).get(student_id)
  198 + if student is not None:
  199 + passw = (args.pw or student_id).encode('utf-8')
  200 + student.password = bcrypt.hashpw(passw, bcrypt.gensalt())
191 session.commit() 201 session.commit()
192 else: 202 else:
193 - print(f'!!! Student {s} does not exist. Skipping update !!!') 203 + print(f'!!! Student {student_id} does not exist. Skipped!!!')
194 204
195 show_students_in_database(session, args.verbose) 205 show_students_in_database(session, args.verbose)
196 206
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
@@ -6,19 +10,20 @@ from contextlib import contextmanager # `with` statement in db sessions @@ -6,19 +10,20 @@ 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 import sqlalchemy as sa
  20 +import sqlalchemy.orm as orm
16 21
17 # this project 22 # 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 23 +from aprendizations.models import Student, Answer, Topic, StudentTopic
  24 +from aprendizations.questions import Question, QFactory, QDict, QuestionException
  25 +from aprendizations.student import StudentState
  26 +from aprendizations.tools import load_yaml
22 27
23 28
24 # setup logger for this module 29 # setup logger for this module
@@ -27,33 +32,37 @@ logger = logging.getLogger(__name__) @@ -27,33 +32,37 @@ logger = logging.getLogger(__name__)
27 32
28 # ============================================================================ 33 # ============================================================================
29 class LearnException(Exception): 34 class LearnException(Exception):
30 - pass 35 + '''Exceptions raised from the LearnApp class'''
31 36
32 37
33 class DatabaseUnusableError(LearnException): 38 class DatabaseUnusableError(LearnException):
34 - pass 39 + '''Exception raised if the database fails in the initialization'''
35 40
36 41
37 # ============================================================================ 42 # ============================================================================
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(...) 43 +class LearnApp():
  44 + '''
  45 + LearnApp - application logic
  46 +
  47 + self.deps - networkx topic dependencies
  48 + self.courses - dict {course_id: {'title': ...,
  49 + 'description': ...,
  50 + 'goals': ...,}, ...}
  51 + self.factory = dict {qref: QFactory()}
  52 + self.online - dict {student_id: {'number': ...,
  53 + 'name': ...,
  54 + 'state': StudentState(),
  55 + 'counter': ...}, ...}
  56 + '''
  57 +
  58 +
54 # ------------------------------------------------------------------------ 59 # ------------------------------------------------------------------------
55 @contextmanager 60 @contextmanager
56 - def db_session(self, **kw): 61 + def _db_session(self, **kw):
  62 + '''
  63 + helper to manage db sessions using the `with` statement, for example
  64 + with self._db_session() as s: s.query(...)
  65 + '''
57 session = self.Session(**kw) 66 session = self.Session(**kw)
58 try: 67 try:
59 yield session 68 yield session
@@ -66,142 +75,150 @@ class LearnApp(object): @@ -66,142 +75,150 @@ class LearnApp(object):
66 session.close() 75 session.close()
67 76
68 # ------------------------------------------------------------------------ 77 # ------------------------------------------------------------------------
69 - # init  
70 - # ------------------------------------------------------------------------  
71 def __init__(self, 78 def __init__(self,
72 courses: str, # filename with course configurations 79 courses: str, # filename with course configurations
73 prefix: str, # path to topics 80 prefix: str, # path to topics
74 db: str, # database filename 81 db: str, # database filename
75 check: bool = False) -> None: 82 check: bool = False) -> None:
76 83
77 - self.db_setup(db) # setup database and check students 84 + self._db_setup(db) # setup database and check students
78 self.online: Dict[str, Dict] = dict() # online students 85 self.online: Dict[str, Dict] = dict() # online students
79 86
80 try: 87 try:
81 config: Dict[str, Any] = load_yaml(courses) 88 config: Dict[str, Any] = load_yaml(courses)
82 - except Exception: 89 + except Exception as exc:
83 msg = f'Failed to load yaml file "{courses}"' 90 msg = f'Failed to load yaml file "{courses}"'
84 logger.error(msg) 91 logger.error(msg)
85 - raise LearnException(msg) 92 + raise LearnException(msg) from exc
86 93
87 # --- topic dependencies are shared between all courses 94 # --- topic dependencies are shared between all courses
88 self.deps = nx.DiGraph(prefix=prefix) 95 self.deps = nx.DiGraph(prefix=prefix)
89 logger.info('Populating topic graph:') 96 logger.info('Populating topic graph:')
90 97
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 98 + # topics defined directly in the courses file, usually empty
  99 + base_topics = config.get('topics', {})
  100 + self._populate_graph(base_topics)
  101 + logger.info('%6d topics in %s', len(base_topics), courses)
  102 +
  103 + # load other course files with the topics the their deps
  104 + for course_file in config.get('topics_from', []):
  105 + course_conf = load_yaml(course_file) # course configuration
96 # FIXME set defaults?? 106 # 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') 107 + logger.info('%6d topics imported from %s',
  108 + len(course_conf["topics"]), course_file)
  109 + self._populate_graph(course_conf)
  110 + logger.info('Graph has %d topics', len(self.deps))
100 111
101 # --- courses dict 112 # --- courses dict
102 self.courses = config['courses'] 113 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']: 114 + logger.info('Courses: %s', ', '.join(self.courses.keys()))
  115 + for cid, course in self.courses.items():
  116 + course.setdefault('title', cid) # course title undefined
  117 + for goal in course['goals']:
107 if goal not in self.deps.nodes(): 118 if goal not in self.deps.nodes():
108 - msg = f'Goal "{goal}" from course "{c}" does not exist' 119 + msg = f'Goal "{goal}" from course "{cid}" does not exist'
109 logger.error(msg) 120 logger.error(msg)
110 raise LearnException(msg) 121 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']] 122 + if self.deps.nodes[goal]['type'] == 'chapter':
  123 + course['goals'] += [g for g in self.deps.predecessors(goal)
  124 + if g not in course['goals']]
114 125
115 # --- factory is a dict with question generators for all topics 126 # --- factory is a dict with question generators for all topics
116 - self.factory: Dict[str, QFactory] = self.make_factory() 127 + self.factory: Dict[str, QFactory] = self._make_factory()
117 128
118 # if graph has topics that are not in the database, add them 129 # if graph has topics that are not in the database, add them
119 - self.add_missing_topics(self.deps.nodes()) 130 + self._add_missing_topics(self.deps.nodes())
120 131
121 if check: 132 if check:
122 - self.sanity_check_questions() 133 + self._sanity_check_questions()
123 134
124 # ------------------------------------------------------------------------ 135 # ------------------------------------------------------------------------
125 - def sanity_check_questions(self) -> None: 136 + def _sanity_check_questions(self) -> None:
  137 + '''
  138 + Unity tests for all questions
  139 +
  140 + Generates all questions, give right and wrong answers and corrects.
  141 + '''
126 logger.info('Starting sanity checks (may take a while...)') 142 logger.info('Starting sanity checks (may take a while...)')
127 143
128 errors: int = 0 144 errors: int = 0
129 for qref in self.factory: 145 for qref in self.factory:
130 - logger.debug(f'checking {qref}...') 146 + logger.debug('checking %s...', qref)
131 try: 147 try:
132 - q = self.factory[qref].generate()  
133 - except QuestionException as e:  
134 - logger.error(e) 148 + question = self.factory[qref].generate()
  149 + except QuestionException as exc:
  150 + logger.error(exc)
135 errors += 1 151 errors += 1
136 continue # to next question 152 continue # to next question
137 153
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}".') 154 + if 'tests_right' in question:
  155 + for right_answer in question['tests_right']:
  156 + question['answer'] = right_answer
  157 + question.correct()
  158 + if question['grade'] < 1.0:
  159 + logger.error('Failed right answer in "%s".', qref)
144 errors += 1 160 errors += 1
145 continue # to next test 161 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}".') 162 + elif question['type'] == 'textarea':
  163 + msg = f'- consider adding tests to {question["ref"]}'
  164 + logger.warning(msg)
  165 +
  166 + if 'tests_wrong' in question:
  167 + for wrong_answer in question['tests_wrong']:
  168 + question['answer'] = wrong_answer
  169 + question.correct()
  170 + if question['grade'] >= 1.0:
  171 + logger.error('Failed wrong answer in "%s".', qref)
153 errors += 1 172 errors += 1
154 continue # to next test 173 continue # to next test
155 174
156 if errors > 0: 175 if errors > 0:
157 - logger.error(f'{errors:>6} error(s) found.') 176 + logger.error('%6d error(s) found.', errors) # {errors:>6}
158 raise LearnException('Sanity checks') 177 raise LearnException('Sanity checks')
159 - else:  
160 - logger.info(' 0 errors found.') 178 + logger.info(' 0 errors found.')
161 179
162 # ------------------------------------------------------------------------ 180 # ------------------------------------------------------------------------
163 - # login  
164 - # ------------------------------------------------------------------------  
165 - async def login(self, uid: str, pw: str) -> bool: 181 + async def login(self, uid: str, password: str) -> bool:
  182 + '''user login'''
166 183
167 - with self.db_session() as s:  
168 - found = s.query(Student.name, Student.password) \  
169 - .filter_by(id=uid) \  
170 - .one_or_none() 184 + with self._db_session() as sess:
  185 + found = sess.query(Student.name, Student.password) \
  186 + .filter_by(id=uid) \
  187 + .one_or_none()
171 188
172 # wait random time to minimize timing attacks 189 # wait random time to minimize timing attacks
173 await asyncio.sleep(random()) 190 await asyncio.sleep(random())
174 191
175 loop = asyncio.get_running_loop() 192 loop = asyncio.get_running_loop()
176 if found is None: 193 if found is None:
177 - logger.info(f'User "{uid}" does not exist') 194 + logger.info('User "%s" does not exist', uid)
178 await loop.run_in_executor(None, bcrypt.hashpw, b'', 195 await loop.run_in_executor(None, bcrypt.hashpw, b'',
179 bcrypt.gensalt()) # just spend time 196 bcrypt.gensalt()) # just spend time
180 return False 197 return False
181 198
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) 199 + name, hashed_pw = found
  200 + pw_ok: bool = await loop.run_in_executor(None,
  201 + bcrypt.checkpw,
  202 + password.encode('utf-8'),
  203 + hashed_pw)
188 204
189 if pw_ok: 205 if pw_ok:
190 if uid in self.online: 206 if uid in self.online:
191 - logger.warning(f'User "{uid}" already logged in') 207 + logger.warning('User "%s" already logged in', uid)
192 counter = self.online[uid]['counter'] 208 counter = self.online[uid]['counter']
193 else: 209 else:
194 - logger.info(f'User "{uid}" logged in') 210 + logger.info('User "%s" logged in', uid)
195 counter = 0 211 counter = 0
196 212
197 # get topics of this student and set its current state 213 # 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) 214 + with self._db_session() as sess:
  215 + student_topics = sess.query(StudentTopic) \
  216 + .filter_by(student_id=uid)
200 217
201 state = {t.topic_id: { 218 state = {t.topic_id: {
202 'level': t.level, 219 'level': t.level,
203 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f") 220 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f")
204 - } for t in tt} 221 + } for t in student_topics}
205 222
206 self.online[uid] = { 223 self.online[uid] = {
207 'number': uid, 224 'number': uid,
@@ -213,178 +230,194 @@ class LearnApp(object): @@ -213,178 +230,194 @@ class LearnApp(object):
213 } 230 }
214 231
215 else: 232 else:
216 - logger.info(f'User "{uid}" wrong password') 233 + logger.info('User "%s" wrong password', uid)
217 234
218 return pw_ok 235 return pw_ok
219 236
220 # ------------------------------------------------------------------------ 237 # ------------------------------------------------------------------------
221 - # logout  
222 - # ------------------------------------------------------------------------  
223 def logout(self, uid: str) -> None: 238 def logout(self, uid: str) -> None:
  239 + '''User logout'''
224 del self.online[uid] 240 del self.online[uid]
225 - logger.info(f'User "{uid}" logged out') 241 + logger.info('User "%s" logged out', uid)
226 242
227 # ------------------------------------------------------------------------ 243 # ------------------------------------------------------------------------
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: 244 + async def change_password(self, uid: str, password: str) -> bool:
  245 + '''
  246 + Change user Password.
  247 + Returns True if password is successfully changed
  248 + '''
  249 + if not password:
232 return False 250 return False
233 251
234 loop = asyncio.get_running_loop() 252 loop = asyncio.get_running_loop()
235 - pw = await loop.run_in_executor(None, bcrypt.hashpw,  
236 - pw.encode('utf-8'), bcrypt.gensalt()) 253 + pw = await loop.run_in_executor(None,
  254 + bcrypt.hashpw,
  255 + password.encode('utf-8'),
  256 + bcrypt.gensalt())
237 257
238 - with self.db_session() as s:  
239 - u = s.query(Student).get(uid)  
240 - u.password = pw 258 + with self._db_session() as sess:
  259 + user = sess.query(Student).get(uid)
  260 + user.password = pw
241 261
242 - logger.info(f'User "{uid}" changed password') 262 + logger.info('User "%s" changed password', uid)
243 return True 263 return True
244 264
245 # ------------------------------------------------------------------------ 265 # ------------------------------------------------------------------------
246 - # Checks answer and update database. Returns corrected question.  
247 - # ------------------------------------------------------------------------  
248 async def check_answer(self, uid: str, answer) -> Question: 266 async def check_answer(self, uid: str, answer) -> Question:
  267 + '''
  268 + Checks answer and update database.
  269 + Returns corrected question.
  270 + '''
249 student = self.online[uid]['state'] 271 student = self.online[uid]['state']
250 await student.check_answer(answer) 272 await student.check_answer(answer)
251 - q: Question = student.get_current_question()  
252 273
253 - logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"') 274 + topic_id = student.get_current_topic()
  275 + question: Question = student.get_current_question()
  276 + grade = question["grade"]
  277 + ref = question["ref"]
  278 +
  279 + logger.info('User "%s" got %.2f in "%s"', uid, grade, ref)
254 280
255 # always save grade of answered question 281 # 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())) 282 + with self._db_session() as sess:
  283 + sess.add(Answer(ref=ref,
  284 + grade=grade,
  285 + starttime=str(question['start_time']),
  286 + finishtime=str(question['finish_time']),
  287 + student_id=uid,
  288 + topic_id=topic_id))
264 289
265 - return q 290 + return question
266 291
267 # ------------------------------------------------------------------------ 292 # ------------------------------------------------------------------------
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]: 293 async def get_question(self, uid: str) -> Optional[Question]:
  294 + '''
  295 + Get the question to show (current or new one)
  296 + If no more questions, save/update level in database
  297 + '''
272 student = self.online[uid]['state'] 298 student = self.online[uid]['state']
273 - q: Optional[Question] = await student.get_question() 299 + question: Optional[Question] = await student.get_question()
274 300
275 # save topic to database if finished 301 # save topic to database if finished
276 if student.topic_has_finished(): 302 if student.topic_has_finished():
277 topic: str = student.get_previous_topic() 303 topic: str = student.get_previous_topic()
278 level: float = student.get_topic_level(topic) 304 level: float = student.get_topic_level(topic)
279 date: str = str(student.get_topic_date(topic)) 305 date: str = str(student.get_topic_date(topic))
280 - logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})') 306 + logger.info('User "%s" finished "%s" (level=%.2f)',
  307 + uid, topic, level)
  308 +
  309 + with self._db_session() as sess:
  310 + student_topic = sess.query(StudentTopic) \
  311 + .filter_by(student_id=uid, topic_id=topic)\
  312 + .one_or_none()
281 313
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: 314 + if student_topic is None:
287 # insert new studenttopic into database 315 # insert new studenttopic into database
288 logger.debug('db insert studenttopic') 316 logger.debug('db insert studenttopic')
289 - t = s.query(Topic).get(topic)  
290 - u = s.query(Student).get(uid) 317 + tid = sess.query(Topic).get(topic)
  318 + uid = sess.query(Student).get(uid)
291 # association object 319 # association object
292 - a = StudentTopic(level=level, date=date, topic=t,  
293 - student=u)  
294 - u.topics.append(a) 320 + student_topic = StudentTopic(level=level, date=date,
  321 + topic=tid, student=uid)
  322 + uid.topics.append(student_topic)
295 else: 323 else:
296 # update studenttopic in database 324 # update studenttopic in database
297 - logger.debug(f'db update studenttopic to level {level}')  
298 - a.level = level  
299 - a.date = date 325 + logger.debug('db update studenttopic to level %f', level)
  326 + student_topic.level = level
  327 + student_topic.date = date
300 328
301 - s.add(a) 329 + sess.add(student_topic)
302 330
303 - return q 331 + return question
304 332
305 # ------------------------------------------------------------------------ 333 # ------------------------------------------------------------------------
306 - # Start course  
307 - # ------------------------------------------------------------------------  
308 def start_course(self, uid: str, course_id: str) -> None: 334 def start_course(self, uid: str, course_id: str) -> None:
  335 + '''Start course'''
  336 +
309 student = self.online[uid]['state'] 337 student = self.online[uid]['state']
310 try: 338 try:
311 student.start_course(course_id) 339 student.start_course(course_id)
312 - except Exception:  
313 - logger.warning(f'"{uid}" could not start course "{course_id}"')  
314 - raise 340 + except Exception as exc:
  341 + logger.warning('"%s" could not start course "%s"', uid, course_id)
  342 + raise LearnException() from exc
315 else: 343 else:
316 - logger.info(f'User "{uid}" started course "{course_id}"') 344 + logger.info('User "%s" started course "%s"', uid, course_id)
317 345
318 # ------------------------------------------------------------------------ 346 # ------------------------------------------------------------------------
319 - # Start new topic 347 + #
320 # ------------------------------------------------------------------------ 348 # ------------------------------------------------------------------------
321 async def start_topic(self, uid: str, topic: str) -> None: 349 async def start_topic(self, uid: str, topic: str) -> None:
  350 + '''Start new topic'''
  351 +
322 student = self.online[uid]['state'] 352 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)) 353 + # if uid == '0':
  354 + # logger.warning('Reloading "%s"', topic) # FIXME should be an option
  355 + # self.factory.update(self._factory_for(topic))
326 356
327 try: 357 try:
328 await student.start_topic(topic) 358 await student.start_topic(topic)
329 - except Exception as e:  
330 - logger.warning(f'User "{uid}" could not start "{topic}": {e}') 359 + except Exception as exc:
  360 + logger.warning('User "%s" could not start "%s": %s',
  361 + uid, topic, str(exc))
331 else: 362 else:
332 - logger.info(f'User "{uid}" started topic "{topic}"') 363 + logger.info('User "%s" started topic "%s"', uid, topic)
333 364
334 # ------------------------------------------------------------------------ 365 # ------------------------------------------------------------------------
335 - # Fill db table 'Topic' with topics from the graph if not already there. 366 + #
336 # ------------------------------------------------------------------------ 367 # ------------------------------------------------------------------------
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)] 368 + def _add_missing_topics(self, topics: Iterable[str]) -> None:
  369 + '''
  370 + Fill db table 'Topic' with topics from the graph, if new
  371 + '''
  372 + with self._db_session() as sess:
  373 + new = [Topic(id=t) for t in topics
  374 + if (t,) not in sess.query(Topic.id)]
341 375
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') 376 + if new:
  377 + sess.add_all(new)
  378 + logger.info('Added %d new topic(s) to the database', len(new))
346 379
347 # ------------------------------------------------------------------------ 380 # ------------------------------------------------------------------------
348 - # setup and check database contents  
349 - # ------------------------------------------------------------------------  
350 - def db_setup(self, db: str) -> None: 381 + def _db_setup(self, database: str) -> None:
  382 + '''setup and check database contents'''
351 383
352 - logger.info(f'Checking database "{db}":')  
353 - if not path.exists(db): 384 + logger.info('Checking database "%s":', database)
  385 + if not exists(database):
354 raise LearnException('Database does not exist. ' 386 raise LearnException('Database does not exist. '
355 'Use "initdb-aprendizations" to create') 387 'Use "initdb-aprendizations" to create')
356 388
357 - engine = sa.create_engine(f'sqlite:///{db}', echo=False)  
358 - self.Session = sa.orm.sessionmaker(bind=engine) 389 + engine = sa.create_engine(f'sqlite:///{database}', echo=False)
  390 + self.Session = orm.sessionmaker(bind=engine)
359 try: 391 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() 392 + with self._db_session() as sess:
  393 + count_students: int = sess.query(Student).count()
  394 + count_topics: int = sess.query(Topic).count()
  395 + count_answers: int = sess.query(Answer).count()
  396 + except Exception as exc:
  397 + logger.error('Database "%s" not usable!', database)
  398 + raise DatabaseUnusableError() from exc
367 else: 399 else:
368 - logger.info(f'{n:6} students')  
369 - logger.info(f'{m:6} topics')  
370 - logger.info(f'{q:6} answers') 400 + logger.info('%6d students', count_students)
  401 + logger.info('%6d topics', count_topics)
  402 + logger.info('%6d answers', count_answers)
371 403
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 # ------------------------------------------------------------------------ 404 # ------------------------------------------------------------------------
381 - def populate_graph(self, config: Dict[str, Any]) -> None:  
382 - g = self.deps # dependency graph 405 + def _populate_graph(self, config: Dict[str, Any]) -> None:
  406 + '''
  407 + Populates a digraph.
  408 +
  409 + Nodes are the topic references e.g. 'my/topic'
  410 + g.nodes['my/topic']['name'] name of the topic
  411 + g.nodes['my/topic']['questions'] list of question refs
  412 +
  413 + Edges are obtained from the deps defined in the YAML file for each topic.
  414 + '''
  415 +
383 defaults = { 416 defaults = {
384 - 'type': 'topic', 417 + 'type': 'topic', # chapter
385 'file': 'questions.yaml', 418 'file': 'questions.yaml',
386 'shuffle_questions': True, 419 'shuffle_questions': True,
387 - 'choose': 9999, 420 + 'choose': 99,
388 'forgetting_factor': 1.0, # no forgetting 421 'forgetting_factor': 1.0, # no forgetting
389 'max_tries': 1, # in every question 422 'max_tries': 1, # in every question
390 'append_wrong': True, 423 'append_wrong': True,
@@ -394,20 +427,21 @@ class LearnApp(object): @@ -394,20 +427,21 @@ class LearnApp(object):
394 427
395 # iterate over topics and populate graph 428 # iterate over topics and populate graph
396 topics: Dict[str, Dict] = config.get('topics', {}) 429 topics: Dict[str, Dict] = config.get('topics', {})
397 - g.add_nodes_from(topics.keys()) 430 + self.deps.add_nodes_from(topics.keys())
398 for tref, attr in topics.items(): 431 for tref, attr in topics.items():
399 - logger.debug(f' + {tref}')  
400 - for d in attr.get('deps', []):  
401 - g.add_edge(d, tref) 432 + logger.debug(' + %s', tref)
  433 + for dep in attr.get('deps', []):
  434 + self.deps.add_edge(dep, tref)
402 435
403 - t = g.nodes[tref] # get current topic node  
404 - t['name'] = attr.get('name', tref)  
405 - t['questions'] = attr.get('questions', []) 436 + topic = self.deps.nodes[tref] # get current topic node
  437 + topic['name'] = attr.get('name', tref)
  438 + topic['questions'] = attr.get('questions', []) # FIXME unused??
406 439
407 for k, default in defaults.items(): 440 for k, default in defaults.items():
408 - t[k] = attr.get(k, default) 441 + topic[k] = attr.get(k, default)
409 442
410 - t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic 443 + # prefix/topic
  444 + topic['path'] = join(self.deps.graph['prefix'], tref)
411 445
412 446
413 # ======================================================================== 447 # ========================================================================
@@ -415,48 +449,45 @@ class LearnApp(object): @@ -415,48 +449,45 @@ class LearnApp(object):
415 # ======================================================================== 449 # ========================================================================
416 450
417 # ------------------------------------------------------------------------ 451 # ------------------------------------------------------------------------
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]: 452 + def _make_factory(self) -> Dict[str, QFactory]:
  453 + '''
  454 + Buils dictionary of question factories
  455 + - visits each topic in the graph,
  456 + - adds factory for each topic.
  457 + '''
423 458
424 logger.info('Building questions factory:') 459 logger.info('Building questions factory:')
425 factory = dict() 460 factory = dict()
426 - g = self.deps  
427 - for tref in g.nodes():  
428 - factory.update(self.factory_for(tref)) 461 + for tref in self.deps.nodes:
  462 + factory.update(self._factory_for(tref))
429 463
430 - logger.info(f'Factory has {len(factory)} questions') 464 + logger.info('Factory has %s questions', len(factory))
431 return factory 465 return factory
432 466
433 # ------------------------------------------------------------------------ 467 # ------------------------------------------------------------------------
434 # makes factory for a single topic 468 # makes factory for a single topic
435 # ------------------------------------------------------------------------ 469 # ------------------------------------------------------------------------
436 - def factory_for(self, tref: str) -> Dict[str, QFactory]: 470 + def _factory_for(self, tref: str) -> Dict[str, QFactory]:
437 factory: Dict[str, QFactory] = dict() 471 factory: Dict[str, QFactory] = dict()
438 - g = self.deps  
439 - t = g.nodes[tref] # get node 472 + topic = self.deps.nodes[tref] # get node
440 # load questions as list of dicts 473 # load questions as list of dicts
441 - topicpath: str = path.join(g.graph['prefix'], tref)  
442 try: 474 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}' 475 + fullpath: str = join(topic['path'], topic['file'])
  476 + except Exception as exc:
  477 + msg = f'Invalid topic "{tref}". Check dependencies of: ' + \
  478 + ', '.join(self.deps.successors(tref))
448 logger.error(msg) 479 logger.error(msg)
449 - raise LearnException(msg)  
450 - logger.debug(f' Loading {fullpath}') 480 + raise LearnException(msg) from exc
  481 + logger.debug(' Loading %s', fullpath)
451 try: 482 try:
452 questions: List[QDict] = load_yaml(fullpath) 483 questions: List[QDict] = load_yaml(fullpath)
453 - except Exception:  
454 - if t['type'] == 'chapter': 484 + except Exception as exc:
  485 + if topic['type'] == 'chapter':
455 return factory # chapters may have no "questions" 486 return factory # chapters may have no "questions"
456 - else:  
457 - msg = f'Failed to load "{fullpath}"'  
458 - logger.error(msg)  
459 - raise LearnException(msg) 487 + msg = f'Failed to load "{fullpath}"'
  488 + logger.error(msg)
  489 + raise LearnException(msg) from exc
  490 +
460 if not isinstance(questions, list): 491 if not isinstance(questions, list):
461 msg = f'File "{fullpath}" must be a list of questions' 492 msg = f'File "{fullpath}" must be a list of questions'
462 logger.error(msg) 493 logger.error(msg)
@@ -467,134 +498,163 @@ class LearnApp(object): @@ -467,134 +498,163 @@ class LearnApp(object):
467 # undefined are set to topic:n, where n is the question number 498 # undefined are set to topic:n, where n is the question number
468 # within the file 499 # within the file
469 localrefs: Set[str] = set() # refs in current file 500 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 501 + for i, question in enumerate(questions):
  502 + qref = question.get('ref', str(i)) # ref or number
472 if qref in localrefs: 503 if qref in localrefs:
473 - msg = f'Duplicate ref "{qref}" in "{topicpath}"' 504 + msg = f'Duplicate ref "{qref}" in "{topic["path"]}"'
  505 + logger.error(msg)
474 raise LearnException(msg) 506 raise LearnException(msg)
475 localrefs.add(qref) 507 localrefs.add(qref)
476 508
477 - q['ref'] = f'{tref}:{qref}'  
478 - q['path'] = topicpath  
479 - q.setdefault('append_wrong', t['append_wrong']) 509 + question['ref'] = f'{tref}:{qref}'
  510 + question['path'] = topic['path']
  511 + question.setdefault('append_wrong', topic['append_wrong'])
480 512
481 # if questions are left undefined, include all. 513 # if questions are left undefined, include all.
482 - if not t['questions']:  
483 - t['questions'] = [q['ref'] for q in questions] 514 + if not topic['questions']:
  515 + topic['questions'] = [q['ref'] for q in questions]
484 516
485 - t['choose'] = min(t['choose'], len(t['questions'])) 517 + topic['choose'] = min(topic['choose'], len(topic['questions']))
486 518
487 - for q in questions:  
488 - if q['ref'] in t['questions']:  
489 - factory[q['ref']] = QFactory(q)  
490 - logger.debug(f' + {q["ref"]}') 519 + for question in questions:
  520 + if question['ref'] in topic['questions']:
  521 + factory[question['ref']] = QFactory(question)
  522 + logger.debug(' + %s', question["ref"])
491 523
492 - logger.info(f'{len(t["questions"]):6} questions in {tref}') 524 + logger.info('%6d questions in %s', len(topic["questions"]), tref)
493 525
494 return factory 526 return factory
495 527
496 # ------------------------------------------------------------------------ 528 # ------------------------------------------------------------------------
497 def get_login_counter(self, uid: str) -> int: 529 def get_login_counter(self, uid: str) -> int:
  530 + '''login counter''' # FIXME
498 return int(self.online[uid]['counter']) 531 return int(self.online[uid]['counter'])
499 532
500 # ------------------------------------------------------------------------ 533 # ------------------------------------------------------------------------
501 def get_student_name(self, uid: str) -> str: 534 def get_student_name(self, uid: str) -> str:
  535 + '''Get the username'''
502 return self.online[uid].get('name', '') 536 return self.online[uid].get('name', '')
503 537
504 # ------------------------------------------------------------------------ 538 # ------------------------------------------------------------------------
505 def get_student_state(self, uid: str) -> List[Dict[str, Any]]: 539 def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
  540 + '''Get the knowledge state of a given user'''
506 return self.online[uid]['state'].get_knowledge_state() 541 return self.online[uid]['state'].get_knowledge_state()
507 542
508 # ------------------------------------------------------------------------ 543 # ------------------------------------------------------------------------
509 def get_student_progress(self, uid: str) -> float: 544 def get_student_progress(self, uid: str) -> float:
  545 + '''Get the current topic progress of a given user'''
510 return float(self.online[uid]['state'].get_topic_progress()) 546 return float(self.online[uid]['state'].get_topic_progress())
511 547
512 # ------------------------------------------------------------------------ 548 # ------------------------------------------------------------------------
513 def get_current_question(self, uid: str) -> Optional[Question]: 549 def get_current_question(self, uid: str) -> Optional[Question]:
514 - q: Optional[Question] = self.online[uid]['state'].get_current_question()  
515 - return q 550 + '''Get the current question of a given user'''
  551 + question: Optional[Question] = self.online[uid]['state'].get_current_question()
  552 + return question
516 553
517 # ------------------------------------------------------------------------ 554 # ------------------------------------------------------------------------
518 def get_current_question_id(self, uid: str) -> str: 555 def get_current_question_id(self, uid: str) -> str:
  556 + '''Get id of the current question for a given user'''
519 return str(self.online[uid]['state'].get_current_question()['qid']) 557 return str(self.online[uid]['state'].get_current_question()['qid'])
520 558
521 # ------------------------------------------------------------------------ 559 # ------------------------------------------------------------------------
522 def get_student_question_type(self, uid: str) -> str: 560 def get_student_question_type(self, uid: str) -> str:
  561 + '''Get type of the current question for a given user'''
523 return str(self.online[uid]['state'].get_current_question()['type']) 562 return str(self.online[uid]['state'].get_current_question()['type'])
524 563
525 # ------------------------------------------------------------------------ 564 # ------------------------------------------------------------------------
526 - def get_student_topic(self, uid: str) -> str:  
527 - return str(self.online[uid]['state'].get_current_topic()) 565 + # def get_student_topic(self, uid: str) -> str:
  566 + # return str(self.online[uid]['state'].get_current_topic())
528 567
529 # ------------------------------------------------------------------------ 568 # ------------------------------------------------------------------------
530 def get_student_course_title(self, uid: str) -> str: 569 def get_student_course_title(self, uid: str) -> str:
  570 + '''get the title of the current course for a given user'''
531 return str(self.online[uid]['state'].get_current_course_title()) 571 return str(self.online[uid]['state'].get_current_course_title())
532 572
533 # ------------------------------------------------------------------------ 573 # ------------------------------------------------------------------------
534 def get_current_course_id(self, uid: str) -> Optional[str]: 574 def get_current_course_id(self, uid: str) -> Optional[str]:
  575 + '''get the current course (id) of a given user'''
535 cid: Optional[str] = self.online[uid]['state'].get_current_course_id() 576 cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
536 return cid 577 return cid
537 578
538 # ------------------------------------------------------------------------ 579 # ------------------------------------------------------------------------
539 - def get_topic_name(self, ref: str) -> str:  
540 - return str(self.deps.nodes[ref]['name']) 580 + # def get_topic_name(self, ref: str) -> str:
  581 + # return str(self.deps.nodes[ref]['name'])
541 582
542 # ------------------------------------------------------------------------ 583 # ------------------------------------------------------------------------
543 def get_current_public_dir(self, uid: str) -> str: 584 def get_current_public_dir(self, uid: str) -> str:
  585 + '''
  586 + Get the path for the 'public' directory of the current topic of the
  587 + given user.
  588 + E.g. if the user has the active topic 'xpto',
  589 + then returns 'path/to/xpto/public'.
  590 + '''
544 topic: str = self.online[uid]['state'].get_current_topic() 591 topic: str = self.online[uid]['state'].get_current_topic()
545 prefix: str = self.deps.graph['prefix'] 592 prefix: str = self.deps.graph['prefix']
546 - return path.join(prefix, topic, 'public') 593 + return join(prefix, topic, 'public')
547 594
548 # ------------------------------------------------------------------------ 595 # ------------------------------------------------------------------------
549 def get_courses(self) -> Dict[str, Dict[str, Any]]: 596 def get_courses(self) -> Dict[str, Dict[str, Any]]:
  597 + '''
  598 + Get dictionary with all courses {'course1': {...}, 'course2': {...}}
  599 + '''
550 return self.courses 600 return self.courses
551 601
552 # ------------------------------------------------------------------------ 602 # ------------------------------------------------------------------------
553 def get_course(self, course_id: str) -> Dict[str, Any]: 603 def get_course(self, course_id: str) -> Dict[str, Any]:
  604 + '''
  605 + Get dictionary {'title': ..., 'description':..., 'goals':...}
  606 + '''
554 return self.courses[course_id] 607 return self.courses[course_id]
555 608
556 # ------------------------------------------------------------------------ 609 # ------------------------------------------------------------------------
557 def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]: 610 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() 611 + '''
  612 + Returns rankings for a certain course_id.
  613 + User where uid have <=2 chars are considered ghosts are hidden from
  614 + the rankings. This is so that there can be users for development or
  615 + testing purposes, which are not real users.
  616 + The user_id of real students must have >2 chars.
  617 + '''
  618 +
  619 + logger.info('User "%s" get rankings for %s', uid, course_id)
  620 + with self._db_session() as sess:
  621 + # all students in the database FIXME only with answers of this course
  622 + students = sess.query(Student.id, Student.name).all()
  623 +
  624 + # topic levels FIXME only topics of this course
  625 + student_topics = sess.query(StudentTopic.student_id,
  626 + StudentTopic.topic_id,
  627 + StudentTopic.level,
  628 + StudentTopic.date).all()
568 629
569 # answer performance 630 # 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()) 631 + total = dict(sess.query(Answer.student_id,
  632 + sa.func.count(Answer.ref)) \
  633 + .group_by(Answer.student_id) \
  634 + .all())
  635 + right = dict(sess.query(Answer.student_id,
  636 + sa.func.count(Answer.ref)) \
  637 + .filter(Answer.grade == 1.0) \
  638 + .group_by(Answer.student_id) \
  639 + .all())
577 640
578 # compute percentage of right answers 641 # compute percentage of right answers
579 - perf: Dict[str, float] = {u: right.get(u, 0.0)/total[u] 642 + perf: Dict[str, float] = {u: right.get(u, 0.0) / total[u]
580 for u in total} 643 for u in total}
581 644
582 # compute topic progress 645 # compute topic progress
583 now = datetime.now() 646 now = datetime.now()
584 goals = self.courses[course_id]['goals'] 647 goals = self.courses[course_id]['goals']
585 - prog: DefaultDict[str, float] = defaultdict(int) 648 + progress: DefaultDict[str, float] = defaultdict(int)
586 649
587 - for u, topic, level, date in student_topics: 650 + for student, topic, level, date in student_topics:
588 if topic in goals: 651 if topic in goals:
589 date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f") 652 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 653 + progress[student] += level**(now - date).days / len(goals)
  654 +
  655 + return sorted(((u, name, progress[u], perf.get(u, 0.0))
  656 + for u, name in students
  657 + if u in progress and (len(u) > 2 or len(uid) <= 2)),
  658 + key=lambda x: x[2], reverse=True)
599 659
600 # ------------------------------------------------------------------------ 660 # ------------------------------------------------------------------------
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 + raise
  201 + # sys.exit(1)
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/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/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/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
@@ -4,28 +4,28 @@ @@ -4,28 +4,28 @@
4 4
5 <head> 5 <head>
6 <title>{{appname}}</title> 6 <title>{{appname}}</title>
7 - <link rel="icon" href="/static/favicon.ico"> 7 + <link rel="icon" href="favicon.ico">
8 <meta charset="utf-8"> 8 <meta charset="utf-8">
9 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 9 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
10 <meta name="author" content="Miguel Barão"> 10 <meta name="author" content="Miguel Barão">
11 <!-- Styles --> 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 - <link rel="stylesheet" href="/static/css/maintopics.css">  
15 - <link rel="stylesheet" href="/static/css/sticky-footer-navbar.css"> 12 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}">
  13 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}">
  14 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
  15 + <link rel="stylesheet" href="{{static_url('css/sticky-footer-navbar.css')}}">
16 <!-- Scripts --> 16 <!-- 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> 17 + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script>
  18 + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script>
  19 + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script>
  20 + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script>
  21 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  22 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
23 </head> 23 </head>
24 24
25 <body> 25 <body>
26 <!-- ===== navbar ==================================================== --> 26 <!-- ===== navbar ==================================================== -->
27 <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> 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"> 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-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation"> 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> 30 <span class="navbar-toggler-icon"></span>
31 </button> 31 </button>
aprendizations/templates/login.html
@@ -9,15 +9,15 @@ @@ -9,15 +9,15 @@
9 <meta name="author" content="Miguel Barão"> 9 <meta name="author" content="Miguel Barão">
10 10
11 <!-- Styles --> 11 <!-- Styles -->
12 - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">  
13 - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css"> 12 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}">
  13 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}">
14 14
15 <!-- Scripts --> 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> 16 + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script>
  17 + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script>
  18 + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script>
  19 + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script>
  20 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
21 21
22 </head> 22 </head>
23 <!-- =================================================================== --> 23 <!-- =================================================================== -->
@@ -28,7 +28,7 @@ @@ -28,7 +28,7 @@
28 <div class="row"> 28 <div class="row">
29 29
30 <div class="col-sm-9"> 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"> 31 + <img src="{{static_url('logo_horizontal_login.png') }}" class="img-responsive mb-3" width="50%" alt="Universidade de Évora">
32 </div> 32 </div>
33 33
34 <div class="col-sm-3"> 34 <div class="col-sm-3">
aprendizations/templates/maintopics-table.html
@@ -11,23 +11,23 @@ @@ -11,23 +11,23 @@
11 <meta name="author" content="Miguel Barão"> 11 <meta name="author" content="Miguel Barão">
12 12
13 <!-- Styles --> 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"> 14 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}">
  15 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}">
  16 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
17 17
18 <!-- Scripts --> 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> 19 + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script>
  20 + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script>
  21 + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script>
  22 + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script>
  23 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  24 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
25 25
26 </head> 26 </head>
27 <!-- ===================================================================== --> 27 <!-- ===================================================================== -->
28 <body> 28 <body>
29 <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> 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"> 30 + <img src="{{static_url('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"> 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> 32 <span class="navbar-toggler-icon"></span>
33 </button> 33 </button>
aprendizations/templates/rankings.html
@@ -11,23 +11,23 @@ @@ -11,23 +11,23 @@
11 <meta name="author" content="Miguel Barão"> 11 <meta name="author" content="Miguel Barão">
12 12
13 <!-- Styles --> 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"> 14 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}">
  15 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}">
  16 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
17 17
18 <!-- Scripts --> 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> 19 + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script>
  20 + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script>
  21 + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script>
  22 + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script>
  23 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  24 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
25 25
26 </head> 26 </head>
27 <!-- ===================================================================== --> 27 <!-- ===================================================================== -->
28 <body> 28 <body>
29 <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> 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"> 30 + <img src="{{static_url('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"> 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> 32 <span class="navbar-toggler-icon"></span>
33 </button> 33 </button>
@@ -69,28 +69,23 @@ @@ -69,28 +69,23 @@
69 </thead> 69 </thead>
70 <tbody> 70 <tbody>
71 {% for i,r in enumerate(rankings) %} 71 {% for i,r in enumerate(rankings) %}
72 - {% if r[0] == uid %}  
73 - <tr class="table-primary"> <!-- this is me -->  
74 - {% else %}  
75 - <tr>  
76 - {% end %}  
77 - <td class="text-center"> <!-- rank -->  
78 - <strong>  
79 - {{'<i class="fas fa-crown fa-2x text-warning"></i>' if i==0 else i+1}}  
80 - </strong>  
81 - </td>  
82 - <td> <!-- student name -->  
83 - {{ ' '.join(r[1].split()[n] for n in (0,-1)) }}  
84 - &nbsp;  
85 - {{ '<i class="far fa-thumbs-up text-success" title="Mais de 75% de respostas correctas"></i>' if r[3] > 0.75 else '' }}  
86 - {{ '<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> 72 + <tr class="{{ 'table-warning' if r[0] == uid else '' }}">
  73 + <td class="text-center"> <!-- rank -->
  74 + <strong>
  75 + {{ '<i class="fas fa-crown fa-2x text-warning"></i>' if i==0 else i+1 }}
  76 + </strong>
  77 + </td>
  78 + <td> <!-- student name -->
  79 + {{ ' '.join(r[1].split()[n] for n in (0,-1)) }}
  80 + &nbsp;
  81 + {{ '<i class="far fa-thumbs-up text-success" title="Mais de 75% de respostas correctas"></i>' if r[3] > 0.75 else '' }}
  82 + </td>
  83 + <td> <!-- progress -->
  84 + <div class="progress">
  85 + <div class="progress-bar" role="progressbar" style="width: {{100*r[2]}}%;" aria-valuenow="{{round(100*r[2])}}%" aria-valuemin="0" aria-valuemax="100">{{round(100*r[2])}}%</div>
  86 + </div>
  87 + </td>
  88 + </tr>
94 {% end %} 89 {% end %}
95 </tbody> 90 </tbody>
96 </table> 91 </table>
aprendizations/templates/topic.html
1 -<!doctype html>  
2 -<html>  
3 - 1 +<!DOCTYPE html>
  2 +<html lang="pt-PT">
4 <head> 3 <head>
5 <title>{{appname}}</title> 4 <title>{{appname}}</title>
6 - <link rel="icon" href="/static/favicon.ico">  
7 -  
8 <meta charset="utf-8"> 5 <meta charset="utf-8">
9 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no"> 6 <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
10 <meta name="author" content="Miguel Barão"> 7 <meta name="author" content="Miguel Barão">
  8 + <link rel="icon" href="/static/favicon.ico">
11 9
12 <!-- MathJax3 --> 10 <!-- MathJax3 -->
13 <script> 11 <script>
14 MathJax = { 12 MathJax = {
15 tex: { 13 tex: {
16 - inlineMath: [  
17 - ['$$$', '$$$'],  
18 - ['\\(', '\\)']  
19 - ] 14 + inlineMath: [['$$$', '$$$'], ['\\(', '\\)']]
20 }, 15 },
21 svg: { 16 svg: {
22 fontCache: 'global' 17 fontCache: 'global'
23 } 18 }
24 }; 19 };
25 </script> 20 </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 --> 21 <!-- 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> 22 + <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
  23 + <!-- <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg-full.js"></script> -->
  24 + <script defer src="{{static_url('mdbootstrap/js/jquery.min.js')}}"></script>
  25 + <script defer src="{{static_url('mdbootstrap/js/popper.min.js')}}"></script>
  26 + <script defer src="{{static_url('mdbootstrap/js/bootstrap.min.js')}}"></script>
  27 + <script defer src="{{static_url('mdbootstrap/js/mdb.min.js')}}"></script>
  28 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  29 + <script defer src="{{static_url('codemirror/lib/codemirror.js')}}"></script>
  30 + <script defer src="{{static_url('js/topic.js')}}"></script>
  31 +
  32 + <!-- Styles -->
  33 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}">
  34 + <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}">
  35 + <link rel="stylesheet" href="{{static_url('codemirror/lib/codemirror.css')}}">
  36 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
  37 +<!-- <link rel="stylesheet" href="{{static_url('css/animate.min.css')}}"> -->
  38 + <link rel="stylesheet" href="{{static_url('css/github.css')}}">
  39 + <link rel="stylesheet" href="{{static_url('css/topic.css')}}">
42 </head> 40 </head>
43 <!-- ===================================================================== --> 41 <!-- ===================================================================== -->
44 42
45 <body> 43 <body>
  44 + <!-- Progress bar -->
  45 + <div class="progress fixed-top" style="height: 70px; border-radius: 0px;">
  46 + <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>
  47 + </div>
  48 +
46 <!-- Navbar --> 49 <!-- Navbar -->
47 <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary"> 50 <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"> 51 + <img src="{{static_url('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"> 52 <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> 53 <span class="navbar-toggler-icon"></span>
51 </button> 54 </button>
@@ -70,12 +73,8 @@ @@ -70,12 +73,8 @@
70 </div> 73 </div>
71 </nav> 74 </nav>
72 <!-- ===================================================================== --> 75 <!-- ===================================================================== -->
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>  
76 - <!-- ===================================================================== -->  
77 <!-- main panel with questions --> 76 <!-- main panel with questions -->
78 - <div class="container" id="container"> 77 + <div class="container" id="container" style="padding-top: 100px;">
79 <div id="notifications"></div> 78 <div id="notifications"></div>
80 <div class="my-5" id="content"> 79 <div class="my-5" id="content">
81 <form action="/question" method="post" id="question_form" autocomplete="off"> 80 <form action="/question" method="post" id="question_form" autocomplete="off">
@@ -101,5 +100,4 @@ @@ -101,5 +100,4 @@
101 <!-- title="Shift-Enter" --> 100 <!-- title="Shift-Enter" -->
102 </div> 101 </div>
103 </body> 102 </body>
104 -  
105 -</html>  
106 \ No newline at end of file 103 \ No newline at end of file
  104 +</html>
aprendizations/tools.py
@@ -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 )
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.2",
  9 + "codemirror": "^5.59.1",
  10 + "mdbootstrap": "^4.19.2"
  11 + }
  12 + },
  13 + "node_modules/@fortawesome/fontawesome-free": {
  14 + "version": "5.15.3",
  15 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.3.tgz",
  16 + "integrity": "sha512-rFnSUN/QOtnOAgqFRooTA3H57JLDm0QEG/jPdk+tLQNL/eWd+Aok8g3qCI+Q1xuDPWpGW/i9JySpJVsq8Q0s9w==",
  17 + "hasInstallScript": true,
  18 + "engines": {
  19 + "node": ">=6"
  20 + }
  21 + },
  22 + "node_modules/codemirror": {
  23 + "version": "5.61.1",
  24 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.61.1.tgz",
  25 + "integrity": "sha512-+D1NZjAucuzE93vJGbAaXzvoBHwp9nJZWWWF9utjv25+5AZUiah6CIlfb4ikG4MoDsFsCG8niiJH5++OO2LgIQ=="
  26 + },
  27 + "node_modules/mdbootstrap": {
  28 + "version": "4.19.2",
  29 + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.19.2.tgz",
  30 + "integrity": "sha512-a+LwPflYRYwlmYKTvftW0X7SfOMrRZ02qZjrssNko1lPU/HR5JRFc1uwa3Dmmw+6TwsYH760waqdghBFrucpOw=="
  31 + }
  32 + },
4 "dependencies": { 33 "dependencies": {
5 "@fortawesome/fontawesome-free": { 34 "@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==" 35 + "version": "5.15.3",
  36 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.3.tgz",
  37 + "integrity": "sha512-rFnSUN/QOtnOAgqFRooTA3H57JLDm0QEG/jPdk+tLQNL/eWd+Aok8g3qCI+Q1xuDPWpGW/i9JySpJVsq8Q0s9w=="
9 }, 38 },
10 "codemirror": { 39 "codemirror": {
11 - "version": "5.51.0",  
12 - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.51.0.tgz",  
13 - "integrity": "sha512-vyuYYRv3eXL0SCuZA4spRFlKNzQAewHcipRQCOKgRy7VNAvZxTKzbItdbCl4S5AgPZ5g3WkHp+ibWQwv9TLG7Q==" 40 + "version": "5.61.1",
  41 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.61.1.tgz",
  42 + "integrity": "sha512-+D1NZjAucuzE93vJGbAaXzvoBHwp9nJZWWWF9utjv25+5AZUiah6CIlfb4ikG4MoDsFsCG8niiJH5++OO2LgIQ=="
14 }, 43 },
15 "mdbootstrap": { 44 "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==" 45 + "version": "4.19.2",
  46 + "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.19.2.tgz",
  47 + "integrity": "sha512-a+LwPflYRYwlmYKTvftW0X7SfOMrRZ02qZjrssNko1lPU/HR5JRFc1uwa3Dmmw+6TwsYH760waqdghBFrucpOw=="
19 } 48 }
20 } 49 }
21 } 50 }
@@ -2,9 +2,9 @@ @@ -2,9 +2,9 @@
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.2",
  6 + "codemirror": "^5.59.1",
  7 + "mdbootstrap": "^4.19.2"
8 }, 8 },
9 "private": true 9 "private": true
10 } 10 }
@@ -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 ],