Commit 443a1eead78985ecc296b4c301921bea4c2992cb

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

Update to latest sqlalchemy 1.4, etc.

Use bootstrap instead of material themed version.
Lot's of small changes and fixes.
BUGS.md
1 1  
2 2 # BUGS
3 3  
4   -- nao esta a seguir o max_tries definido no ficheiro de dependencias.
  4 +- se na especificacao de um curso, a referencia do topico nao existir como directorio, rebenta.
  5 +- internal server error ao fazer logout no macos python3.8
  6 +- GET can get filtered by browser cache
  7 +- topicos chapter devem ser automaticamente completos assim que as dependencias
  8 + são satisfeitas. Nao devia ser necessario (ou possivel?) clicar neles.
  9 +- topicos do tipo learn deviam por defeito nao ser randomizados e assumir
  10 + ficheiros `learn.yaml`.
  11 +- internal server error 500... experimentar cenario: aluno tem login efectuado,
  12 + prof muda pw e faz login/logout. aluno obtem erro 500.
  13 +- radio sem options rebenta com aprendizations --check
  14 +- chapters deviam ser mostrados unlocked, antes de mostrar a medalha. alunos
  15 + pensam que já terminaram e não conseguem progredir por causa das
  16 + dependencias.
  17 +- if topic deps on invalid ref terminates server with "Unknown error".
  18 +- warning nos topics que não são usados em nenhum curso
  19 +- nao esta a seguir o `max_tries` definido no ficheiro de dependencias.
5 20 - devia mostrar timeout para o aluno saber a razao.
6 21 - permitir configuracao para escolher entre static files locais ou remotos
7 22 - shift-enter não está a funcionar
... ... @@ -9,67 +24,93 @@
9 24  
10 25 # TODO
11 26  
12   -- alterar tabelas para incluir email de recuperacao de password (e outros avisos)
13   -- registar last_seen e remover os antigos de cada vez que houver um login.
  27 +- shuffle das perguntas dentro de um topico
  28 +- alterar tabelas para incluir email de recuperacao de password (e outros
  29 + avisos)
  30 +- registar `last_seen` e remover os antigos de cada vez que houver um login.
14 31 - indicar qtos topicos faltam (>=50%) para terminar o curso.
15 32 - ao fim de 3 tentativas com password errada, envia email com nova password.
16   -- mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo expande as dependencias.
17   -- mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado topicos.
  33 +- mostrar capitulos e subtopicos de forma hierarquica. clicar no capitulo
  34 + expande as dependencias.
  35 +- mostrar rankings alunos/perguntas respondidas/% correctas/valor esperado
  36 + topicos.
18 37 - botão não sei...
19 38 - mostrar icon "loading..." enquanto está a corrigir uma pergunta.
20 39 - session management. close after inactive time.
21 40 - radio e checkboxes, aceitar numeros como seleccao das opcoes.
22   -- reload das perguntas enquanto online. ver signal em http://stackabuse.com/python-async-await-tutorial/
  41 +- reload das perguntas enquanto online. ver signal em
  42 + [](http://stackabuse.com/python-async-await-tutorial/)
23 43 - tabela de progresso de todos os alunos por topico.
24 44 - tabela com perguntas / quantidade de respostas certas/erradas.
25 45 - tabela com topicos / quantidade de estrelas.
26 46 - pymips: activar/desactivar instruções
27 47 - titulos das perguntas não suportam markdown.
28   -- pagina report que permita ver tabela alunos/topicos, estatisticas perguntas mais falhadas, tempo médio por pergunta.
  48 +- pagina report que permita ver tabela alunos/topicos, estatisticas perguntas
  49 + mais falhadas, tempo médio por pergunta.
29 50 - normalizar com perguntations.
30 51  
31 52 # FIXED
32 53  
33   -- templates question-*.html tem input hidden question_ref que não é usado. remover?
  54 +- templates question-*.html tem input hidden question_ref que não é usado.
  55 + remover?
34 56 - goals se forem do tipo chapter deve importar todas as dependencias do chapter.
35   -- initdb da integrity error se no mesmo comando existirem alunos repetidos (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
36   -- dependencias que não são goals de um curso, só devem aparecer se ainda não tiverem sido feitas.
  57 +- initdb da integrity error se no mesmo comando existirem alunos repetidos
  58 + (p.ex em ficheiros csv diferentes ou entre csv e opcao -a)
  59 +- dependencias que não são goals de um curso, só devem aparecer se ainda não
  60 + tiverem sido feitas.
37 61 - ir para inicio da pagina quando le nova pergunta.
38 62 - CRITICAL nao esta a guardar o progresso na base de dados.
39 63 - mesma ref no mesmo ficheiro não é detectado.
40 64 - enter nas respostas mostra json
41   -- apos clicar no botao responder, inactivar o input (importante quando o tempo de correcção é grande)
  65 +- apos clicar no botao responder, inactivar o input (importante quando o tempo
  66 + de correcção é grande)
42 67 - double click submits twice.
43   -- checkbox devia ter correct no intervalo [0,1] tal como radio. em caso de desconto a correccção faz 2*x-1. isto permite a mesma semantica nos dois tipos de perguntas.
  68 +- checkbox devia ter correct no intervalo [0,1] tal como radio. em caso de
  69 + desconto a correccção faz 2*x-1. isto permite a mesma semantica nos dois
  70 + tipos de perguntas.
44 71 - marking all options right in a radio question breaks!
45 72 - implementar servidor http com redirect para https.
46 73 - tabelas nas perguntas radio/checkbox não ocupam todo o espaço como em question.
47 74 - click numa opcao checkbox fora da checkbox+label não está a funcionar.
48   -- mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam centradas em toda a coluna mas apenas na largura do parágrafo.
49   -- QFactory.generate() devia fazer run da gen_async, ou remover.
  75 +- mathjax, formulas $$f(x)$$ nas opções de escolha multipla, não ficam
  76 + centradas em toda a coluna mas apenas na largura do parágrafo.
  77 +- QFactory.generate() devia fazer run da `gen_async,` ou remover.
50 78 - classificacoes so devia mostrar os que ja fizeram alguma coisa
51 79 - impedir que quando students.db não é encontrado, crie um ficheiro vazio.
52   -- permite definir goal, mas nao verifica se esta no grafo. rebenta no start_topic.
53   -- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as imagengs porque o current_topic passa a None antes de carregar no botao continuar. O caminho é prefix+None e dá erro.
  80 +- permite definir goal, mas nao verifica se esta no grafo. rebenta no
  81 + `start_topic`.
  82 +- se num topico, a ultima pergunta tem imagens, o servidor nao fornece as
  83 + imagengs porque o `current_topic` passa a None antes de carregar no botao
  84 + continuar. O caminho é prefix+None e dá erro.
54 85 - caixas com os cursos não se ajustam bem com ecran estreito.
55   -- obter rankings por curso GET course=course_id
56   -- no curso de linear algebra, as perguntas estao shuffled, mas nao deviam estar... nao esta a obedecer a keyword shuffle.
  86 +- obter rankings por curso `GET course=course_id`
  87 +- no curso de linear algebra, as perguntas estao shuffled, mas nao deviam
  88 + estar... nao esta a obedecer a keyword shuffle.
57 89 - menu nao mostra as opcoes correctamente
58 90 - finish topic vai para a lista de cursos. devia ficar no mesmo curso.
59 91 - mathjax nao esta a correr sobre o titulo.
60 92 - forgetting factor is hardcoded in student.py
61 93 - add aprendizatons --version
62   -- se aluno abre dois tabs no browser, conseque navegar em simultaneo para perguntas diferentes. quando submete uma delas dá asneira. Tem de haver um campo hidden que tenha um céodigo único que indique qual a pergunta. do lado do servidor apnas há o codigo da pergunta corrente, se forem diferentes faz redirect para /.
  94 +- se aluno abre dois tabs no browser, conseque navegar em simultaneo para
  95 + perguntas diferentes. quando submete uma delas dá asneira. Tem de haver um
  96 + campo hidden que tenha um céodigo único que indique qual a pergunta. do lado
  97 + do servidor apnas há o codigo da pergunta corrente, se forem diferentes faz
  98 + redirect para /.
63 99 - nos topicos learn.yaml, qd falha acrescenta no fim. nao faz sentido.
64   -- não esta a fazer render correcto de tabelas nas opcoes checkbox. e.g. information-theory/source-coding-theory/block-codes
  100 +- não esta a fazer render correcto de tabelas nas opcoes checkbox. e.g.
  101 + `information-theory/source-coding-theory/block-codes`
65 102 - max tries nas perguntas.
66 103 - mostrar feedback/solucoes quando acerta, ou excede max tries.
67   -- quando se pressiona "responde" rapido (enquanto a animacao dura), a pergunta passa para a seguinte sem haver o correspondente redraw, ou seja a proxima resposta nao é a da pergunta mostrada.
  104 +- quando se pressiona "responde" rapido (enquanto a animacao dura), a pergunta
  105 + passa para a seguinte sem haver o correspondente redraw, ou seja a proxima
  106 + resposta nao é a da pergunta mostrada.
68 107 - botao para mostrar a solução quando se acerta.
69 108 - não está a guardar o resultado no final do topico
70   -- esta a permitir 2 logins em simultaneo do mesmo user. fica tudo baralhado se mxerem em simultaneo...
  109 +- esta a permitir 2 logins em simultaneo do mesmo user. fica tudo baralhado se
  110 + mxerem em simultaneo...
71 111 - errar no ultimo topico nao mostra solucao?
72   -- quando a pergunta devolve comments, este é apresentado, mas fica persistente nas tentativas seguintes. devia ser limpo apos a segunda submissao.
  112 +- quando a pergunta devolve comments, este é apresentado, mas fica persistente
  113 + nas tentativas seguintes. devia ser limpo apos a segunda submissao.
73 114 - na definicao dos topicos, indicar:
74 115 "file: questions.yaml" (default questions.yaml)
75 116 "shuffle: True/False" (default False)
... ... @@ -84,9 +125,11 @@
84 125 - each topic only loads a sample of K questions (max) in random order.
85 126 - change password modal nao aparece no ipad (safari e firefox)
86 127 - detect questions in questions.yaml without ref -> error ou generate default.
87   -- generators e correct scripts que durem muito tempo bloqueiam o eventloop do tornado.
  128 +- generators e correct scripts que durem muito tempo bloqueiam o eventloop do
  129 + tornado.
88 130 - servir imagens/ficheiros.
89   -- radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma selecção aleatoria destas (so com 1 certa).
  131 +- radio: suporte para multiplas opcoes correctas e erradas, escolhendo-se uma
  132 + selecção aleatoria destas (so com 1 certa).
90 133 - checkbox: cada opção pode ser uma dupla (certo, errado) sendo escolhida uma aleatória.
91 134 - async/threadpool no bcrypt do initdb.
92 135 - numero de estrelas depende da proporcao entre certas e erradas.
... ... @@ -97,10 +140,12 @@
97 140 - remover learn.css uma vez que nao é usado em lado nenhum?
98 141 - check if user already logged in
99 142 - mover javascript para ficheiros externos e carregar com script defer src
100   -- implementar xsrf. Ver [http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection]()
101   -- se refs de um topic estao invalidos, nao carrega esse topico. devia haver um error nos logs a indicar qual o ref invalido.
  143 +- implementar xsrf. Ver [](http://www.tornadoweb.org/en/stable/guide/security.html#cross-site-request-forgery-protection)
  144 +- se refs de um topic estao invalidos, nao carrega esse topico. devia haver um
  145 + error nos logs a indicar qual o ref invalido.
102 146 - link directo para topico nao valida se topico esta unlocked.
103   -- templates not working: quesntion-information, question-warning (remove all informative panels??)
  147 +- templates not working: quesntion-information, question-warning (remove all
  148 + informative panels??)
104 149 - enderecos errados dao internal error.
105 150 - barra de progresso nao está visível.
106 151 - tabs em textarea nao funcionam correctamente (insere 1 espaco em vez de 4)
... ... @@ -110,14 +155,15 @@
110 155 - animação no final de cada topico para se perceber a transição
111 156 - "<" is not escaped in markdown.
112 157 - Está a mostrar a solução em 'comments'!!!
113   -- database: answers não tem referencia para o topico, so para question_ref
  158 +- database: answers não tem referencia para o topico, so para `question_ref`
114 159 - melhorar markdown das tabelas.
115 160 - gravar evolucao na bd no final de cada topico.
116 161 - submeter questoes radio, da erro se nao escolher nenhuma opção.
117 162 - indentação da primeira linha de código não funciona.
118 163 - markdown com o mistune.
119 164 - change password in maintopics.html, falta menu para lançar modal
120   -- ver documentacao de migracao para networkx 2.0 https://networkx.github.io/documentation/stable/release/migration_guide_from_1.x_to_2.0.html
  165 +- ver documentacao de migracao para networkx 2.0
  166 + [](https://networkx.github.io/documentation/stable/release/migration_guide_from_1.x_to_2.0.html)
121 167 - script para adicionar users/reset passwords.
122 168 - os topicos locked devem estar inactivos no sidebar.
123 169 - enter faz GET /question, que responde com json no ecran. (solution: disabled enter)
... ... @@ -126,7 +172,8 @@
126 172 - indicar o topico actual no sidebar
127 173 - reload da página rebenta o estado.
128 174 - text deve mostrar no html os valores iniciais de ans, se existir
129   -- nao permite perguntas repetidas. iterar questions da configuracao em vez das do ficheiro. ver app.py linha 223.
  175 +- nao permite perguntas repetidas. iterar questions da configuracao em vez das
  176 + do ficheiro. ver app.py linha 223.
130 177 - level depender do numero de respostas correctas
131 178 - pymips a funcionar
132 179 - logs mostram que está a gerar cada pergunta 2 vezes...??
... ... @@ -138,15 +185,18 @@
138 185 - se students.db não existe, rebenta.
139 186 - não entra à primeira
140 187 - configuração e linha de comando.
141   -- o browser é redireccionado para /question em vez de fazer um post?? quando se pressiona enter numa caixa text edit.
  188 +- o browser é redireccionado para /question em vez de fazer um post?? quando se
  189 + pressiona enter numa caixa text edit.
142 190 - load/save the knowledge state of the student
143 191 - servir ficheiros de public temporariamente
144 192 - path dos generators scripts mal construido
145 193 - questions hardcoded in LearnApp.
146 194 - Factory para cada pergunta individual em vez de pool
147   -- implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona, enter submete.
  195 +- implementar navegacao radio/checkbox. cursor cima/baixo, espaco selecciona,
  196 + enter submete.
148 197 - logging
149   -- textarea tem codigo para preencher o texto, mas ja não é necessário porque pergunta não é reloaded.
  198 +- textarea tem codigo para preencher o texto, mas ja não é necessário porque
  199 + pergunta não é reloaded.
150 200 - gravar answers -> db
151 201 - como gerar key para secure cookie.
152 202 - https. certificados selfsigned, no-ip nao suporta certificados
... ... @@ -161,4 +211,5 @@
161 211 - clicar texto selecciona checkboxes/radio.
162 212 - focar text/textarea
163 213 - implementar template base das perguntas base e estender para cada tipo.
164   -- submissão com enter em perguntas text faz get? provavelmente está a fazer o submit do form em vez de ir pelo ajax.
  214 +- submissão com enter em perguntas text faz get? provavelmente está a fazer o
  215 + submit do form em vez de ir pelo ajax.
... ...
QUESTIONS.md
1 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 6 ```yaml
6 7 - type: radio
... ... @@ -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 22 The following types of questions are supported:
21 23  
... ... @@ -34,15 +36,16 @@ type | kind of answer
34 36  
35 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 42 The general format is
40 43  
41 44 ```yaml
42 45 - type: radio
43   - ref: question_reference
  46 + ref: question_reference
44 47 title: My first question
45   - text: |
  48 + text: |
46 49 Please select one option.
47 50 options:
48 51 - this one is the correct one
... ... @@ -54,30 +57,37 @@ The general format is
54 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 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 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 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 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 86 ```yaml
77 87 - type: checkbox
78 88 ref: question_reference
79 89 title: My second question
80   - text: |
  90 + text: |
81 91 Please mark the correct options.
82 92 options:
83 93 - this one is correct
... ... @@ -89,16 +99,22 @@ The simplest format is
89 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 119 ```yaml
104 120 options:
... ... @@ -108,11 +124,12 @@ For example,
108 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 134 ```yaml
118 135 options:
... ... @@ -122,7 +139,8 @@ It also minimizes solution memorization. Example:
122 139  
123 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 145 ```yaml
128 146 - type: text
... ... @@ -144,12 +162,14 @@ Similar to text, but answers are validated by a regular expression.
144 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 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 174 ```yaml
155 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 181  
162 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 189 ```yaml
169 190 - type: textarea
... ... @@ -185,7 +206,6 @@ comments: Almost there
185 206  
186 207 It can also just print the grade as a single number.
187 208  
188   -
189 209 ### information, warning, alert and success
190 210  
191 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 221  
202 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 227 ```yaml
208 228 type: generator
... ... @@ -211,16 +231,18 @@ script: executable_program
211 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 235 In the example above, the program to be run is `executable_program`.
216 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 247 ```python
226 248 #!/usr/bin/env python3
... ... @@ -234,7 +256,8 @@ a,b = (int(n) for n in arg.split(&#39;,&#39;))
234 256 q = fr'''
235 257 type: checkbox
236 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 262 Os números foram gerados aleatoriamente no intervalo de {a} a {b}.
240 263 options:
... ... @@ -254,14 +277,14 @@ print(q)
254 277  
255 278 A generator cannot generate another generator, only real questions are acceptable.
256 279  
257   -# Writing text
  280 +## Writing text
258 281  
259 282 The text in the questions is interpreted as markdown with support for LaTeX formulas.
260 283 The best way to write text is to use indentation like this:
261 284  
262 285 ```yaml
263 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 288 and colon would be interpreted as a dictionary key.
266 289  
267 290 Images placed in the `public` subdirectory are accessible by
... ...
README.md
1 1 # Getting Started
2 2  
  3 +Latest review: 2021-07-08
3 4  
4 5 ## Installation
5 6  
6 7 To complete the installation we will need to perform the following steps:
7 8  
8   -1. install python3.7, pip and npm
9   -1. download aprendizations from the repository
10   -1. install javascript libraries (with npm)
11   -1. install aprendizations (with pip)
12   -1. generate SSL certificates
13   -1. configure the firewall (optional)
  9 +1. install python3, pip and npm
  10 +2. download aprendizations from the repository
  11 +3. install javascript libraries (with npm)
  12 +4. install aprendizations (with pip)
  13 +5. generate SSL certificates
  14 +6. configure the firewall (optional)
14 15  
15 16 To use the software we need to:
16 17  
17 18 1. initialize database
18   -1. go to the demo directory (or an existing course)
19   -1. run `aprendizations demo.yaml`
  19 +2. go to the demo directory (or an existing course)
  20 +3. run `aprendizations demo.yaml`
20 21  
21 22 Each of these steps is explained below.
22 23  
23   -### Install python3.7 with sqlite3 support and npm
  24 +### Install python3 with sqlite3 support and npm
24 25  
25   -Python can be installed either from the system package management or compiled from sources.
  26 +Python can be installed either from the system package management or compiled
  27 +from sources.
26 28  
27 29 #### Installing from the system package manager
28 30  
29 31 ```sh
30   -sudo apt install python3.7 npm # Linux (Ubuntu)
31   -sudo pkg install python37 py37-sqlite3 npm # FreeBSD
32   -sudo port install python37 npm6 # MacOS
  32 +sudo pkg install python3 npm # FreeBSD
  33 +sudo apt install python3 npm # Linux (Ubuntu)
  34 +sudo port install python38 npm7 # MacOS
33 35 ```
34 36  
35   -#### Installing from source
36   -
  37 +In FreeBSD also install `py3X-sqlite3` where `X` is the python version.
  38 +
  39 +#### Installing from source (outdated)
  40 +
37 41 Make sure that the build tools and libraries are installed:
38 42  
39 43 ```sh
40 44 # Ubuntu:
41   -sudo apt install build-essential libssl-dev zlib1g-dev libncurses5-dev libncursesw5-dev libreadline-dev libsqlite3-dev libgdbm-dev libdb5.3-dev libbz2-dev libexpat1-dev liblzma-dev tk-dev libffi-dev
  45 +sudo apt install build-essential libssl-dev zlib1g-dev libncurses5-dev \
  46 + libncursesw5-dev libreadline-dev libsqlite3-dev libgdbm-dev libdb5.3-dev \
  47 + libbz2-dev libexpat1-dev liblzma-dev tk-dev libffi-dev
42 48 ```
43 49  
44   -Download python from [http://www.python.org]() and
  50 +Download [python](http://www.python.org) and
45 51  
46 52 ```sh
47 53 tar xvfJ Python-3.7.tar.xz
... ... @@ -50,32 +56,27 @@ cd Python-3.7
50 56 make && make install
51 57 ```
52 58  
53   -This will install python locally under `~/.local/bin`. Make sure to add it to your `PATH` in `~/.profile`. If `~/bin` is already in the path, you may just make a symbolic link `ln -s ~/.local/bin ~/bin`.
  59 +This will install python locally under `~/.local/bin`. Make sure to add it to
  60 +your `PATH` in `~/.profile`. If `~/bin` is already in the path, just make a
  61 +symbolic link `ln -s ~/.local/bin ~/bin`.
54 62  
55 63 ### Install pip
56 64  
57   -Python usually includes pip which is accessible through `python -m pip install something`, but it's also convenient to have the `pip` command directly available in the terminal.
58   -To install `pip` from the system package manager:
  65 +Install `pip` from the system package manager:
59 66  
60 67 ```sh
61   -sudo apt install python3.7-pip # Ubuntu 19.04+
62   -sudo pkg py37-pip # FreeBSD
63   -sudo port install py37-pip # MacOS
  68 +sudo apt install python3-pip # Ubuntu
  69 +sudo pkg py38-pip # FreeBSD
  70 +sudo port install py39-pip # MacOS
64 71 ```
65 72  
66   -otherwise run:
  73 +Then run `python3 -m pip install -U pip` to install latest version into your
  74 +user account under `~/.local/bin`.
  75 +In the end you should be able to run `pip --version` and `python3 -c "import
  76 +sqlite3"` without errors.
  77 +In some systems, `pip` can be named `pip3`, `pip3.8` or `pip-3.8`.
67 78  
68   -```sh
69   -python3.7 -m pip install pip # install in user area
70   -```
71   -
72   -The latter will install `pip` in your user account under `~/.local/bin`.
73   -In the end you should be able to run `pip --version` and
74   -`python3 -c "import sqlite3"` without errors.
75   -Sometimes the `pip` command is named `pip3`,
76   -`pip3.7` or `pip-3.7`.
77   -
78   -Edit the configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or
  79 +Edit the configuration file `~/.config/pip/pip.conf` (FreeBSD, Linux) or
79 80 `Library/Application Support/pip/pip.conf` (MacOS) and add the lines
80 81  
81 82 ```ini
... ... @@ -85,7 +86,7 @@ user = yes
85 86  
86 87 This will set pip to install modules in the user area (recommended).
87 88  
88   -### Download and install aprendizations:
  89 +### Download and install aprendizations
89 90  
90 91 ```sh
91 92 git clone https://git.xdi.uevora.pt/mjsb/aprendizations.git
... ... @@ -94,20 +95,23 @@ npm install # install javascript libraries
94 95 pip install . # install aprendizations and dependencies
95 96 ```
96 97  
97   -Javascript libraries are installed in `aprendizations/node_modules` and are linked from `aprendizations/aprendizations/static`.
  98 +Javascript libraries are installed in `aprendizations/node_modules` and are
  99 +linked from `aprendizations/aprendizations/static`.
98 100  
99 101 Python packages are usually installed in:
100 102  
101   -- `~/.local/lib/python3.7/site-packages/` in Linux/FreeBSD.
102   -- `~/Library/python/3.7/lib/python/site-packages/` in MacOS.
  103 +* `~/.local/lib/python3.8/site-packages/` in Linux/FreeBSD.
  104 +* `~/Library/python/3.9/lib/python/site-packages/` in MacOS.
103 105  
104   -When aprendizations is installed with pip, all the dependencies are also installed. The javascript libraries previously installed with npm are copied to the above directory and the cloned repository is no longer needed.
  106 +When aprendizations is installed with pip, all the dependencies are also
  107 +installed. The javascript libraries previously installed with npm are copied to
  108 +the above directory and the cloned repository is no longer needed.
105 109  
106 110 At this point `aprendizations` is installed in
107 111  
108 112 ```sh
109 113 ~/.local/bin # Linux/FreeBSD
110   -~/Library/Python/3.7/bin # MacOS
  114 +~/Library/Python/3.9/bin # MacOS
111 115 ```
112 116  
113 117 and can be run from the terminal:
... ... @@ -119,10 +123,12 @@ aprendizations --help
119 123  
120 124 ### SSL Certificates
121 125  
122   -We need certificates for https. Certificates can be self-signed or validated by a trusted authority.
  126 +We need certificates for https. Certificates can be self-signed or validated by
  127 +a trusted authority.
123 128  
124   -Self-signed can be used locally for development and testing, but browsers will
125   -complain. LetsEncrypt issues trusted and free certificates, but the server must have a registered publicly accessible domain name.
  129 +Self-signed can be used locally for development and testing, but browsers will
  130 +complain. LetsEncrypt issues trusted and free certificates, but the server must
  131 +have a registered publicly accessible domain name.
126 132  
127 133 #### Generating selfsigned certificates
128 134  
... ... @@ -137,33 +143,36 @@ openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -
137 143 Install the certbot from LetsEncrypt:
138 144  
139 145 ```sh
140   -sudo pkg install py36-certbot # FreeBSD
  146 +sudo pkg install py38-certbot # FreeBSD
141 147 sudo apt install certbot # Ubuntu
142 148 ```
143 149  
144   -To generate or renew the certificates, ports 80 and 443 have to be accessible. The firewall and webserver have to be stopped.
  150 +To generate or renew the certificates, ports 80 and 443 must be accessible.
  151 +**Any firewall and webserver have to be stopped**.
145 152  
146 153 ```sh
147 154 sudo certbot certonly --standalone -d www.example.com # first time
148 155 sudo certbot renew # renew
149 156 ```
150 157  
151   -Certificates are saved under `/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to `~/.local/share/certs` and change permissions to be readable:
  158 +Certificates are saved under
  159 +`/usr/local/etc/letsencrypt/live/www.example.com/`. Copy them to
  160 +`~/.local/share/certs` and change permissions to be readable:
152 161  
153 162 ```sh
  163 +cd ~/.local/share/certs
154 164 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/cert.pem .
155 165 sudo cp /usr/local/etc/letsencrypt/live/www.example.com/privkey.pem .
156 166 chmod 400 cert.pem privkey.pem
157 167 ```
158 168  
159   -
160 169 ## Configuration
161 170  
162 171 ### Database
163 172  
164   -User data is maintained in a sqlite3 database which has to be created manually using the `initdb-aprendizations` command.
165   -The database file should be located in the same directory as the main
166   -YAML configuration file.
  173 +User data is maintained in a sqlite3 database which has to be created manually
  174 +using the `initdb-aprendizations` command. The database file should be located
  175 +in the same directory as the main YAML configuration file.
167 176  
168 177 For example, to run the included demo do:
169 178  
... ... @@ -179,26 +188,27 @@ initdb-aprendizations --help # for available options
179 188  
180 189 The default password is equal to the user name, if left undefined.
181 190  
182   -
183 191 ### Running the demo
184 192  
185   -The application includes a small example in `demo/demo.yaml` that can be used for initial testing. Run it with
  193 +The application includes a small example in `demo/demo.yaml` that can be used
  194 +for initial testing. Run it with
186 195  
187 196 ```sh
188 197 cd demo
189 198 aprendizations demo.yaml
190 199 ```
191 200  
192   -Open the browser at [https://127.0.0.1:8443](https://127.0.0.1:8443).
193   -If everything looks good, check at the correct address
  201 +Open the browser at [https://127.0.0.1:8443](https://127.0.0.1:8443).
  202 +If everything looks good, check at the correct address
194 203 `https://www.example.com:8443`.
195   -The option `--debug` provides more verbose logging and might
196   -be useful during testing.
  204 +The option `--debug` provides more verbose logging and might be useful during
  205 +testing.
197 206  
198 207 ### Firewall configuration
199 208  
200   -Ports 80 and 443 are only usable by root. For security reasons the server runs as an unprivileged user on port 8443 for https.
201   -To access the server in the default https port (443), port forwarding can be configured in the firewall.
  209 +Ports 80 and 443 are only usable by root. For security reasons the server runs
  210 +as an unprivileged user on port 8443 for https. To access the server in the
  211 +default https port (443), port forwarding must be configured in the firewall.
202 212  
203 213 #### FreeBSD and pf
204 214  
... ... @@ -231,14 +241,14 @@ Reboot or `sudo service pf start`.
231 241  
232 242 Make sure the following steps have been done:
233 243  
234   -- installed python3.7, pip and npm
235   -- git-cloned the aprendizations from the main repository
236   -- installed javascript libraries with npm
237   -- installed aprendizations with pip
238   -- initialized database with at least 1 user
239   -- generate and copy certificates to the appropriate place
240   -- (optional) configure the firewall to do port forwarding
241   -- run `aprendizations demo.yaml --check`
  244 +* installed python3, pip and npm
  245 +* git-cloned the aprendizations from the main repository
  246 +* installed javascript libraries with npm
  247 +* installed aprendizations with pip
  248 +* initialized database with at least 1 user
  249 +* generate and copy certificates to the appropriate place
  250 +* (optional) configure the firewall to do port forwarding
  251 +* run `aprendizations demo.yaml --check`
242 252  
243 253 ## Keeping aprendizations updated
244 254  
... ... @@ -248,28 +258,29 @@ To update aprendizations to the latest version do:
248 258 cd aprendizations
249 259 git pull # get latest version
250 260 npm update # update javascript libraries
251   -pip install -U . # updates installed version to latest
  261 +pip install -U . # updates installed version
252 262 ```
253 263  
254 264 ## Troubleshooting
255 265  
256   -To help with troubleshooting, use the option `--debug` when running the server.
257   -This will increase logs in the terminal and will present the python exception
  266 +To help with troubleshooting, use the option `--debug` when running the server.
  267 +This will increase logs in the terminal and will present the python exception
258 268 errors in the browser.
259 269  
260   -Logging levels can be adjusted in `~/.config/aprendizations/logger.yaml` and
  270 +Logging levels can be adjusted in `~/.config/aprendizations/logger.yaml` and
261 271 `~/.config/aprendizations/logger-debug.yaml`.
262 272  
263   -If these files do not yet exist, there are examples in `aprendizations/config` that can be copied to `~/.config/aprendizations`.
  273 +If these files do not yet exist, there are examples in `aprendizations/config`
  274 +that can be copied to `~/.config/aprendizations`.
264 275  
265   -#### UnicodeEncodeError
  276 +### UnicodeEncodeError
266 277  
267   -The server should not generate this error, but when using external scripts to
268   -generate questions or to correct, these scripts can print unicode strings to
269   -stdout. If the terminal does not support unicode, python will generate this
  278 +The server should not generate this error, but when using external scripts to
  279 +generate questions or to correct, these scripts can print unicode strings to
  280 +stdout. If the terminal does not support unicode, python will generate an
270 281 exception.
271 282  
272   -- FreeBSD fix: edit `~/.login_conf` to use UTF-8, for example:
  283 +* FreeBSD fix: edit `~/.login_conf` to use UTF-8, for example:
273 284  
274 285 ```sh
275 286 me:\
... ... @@ -277,7 +288,21 @@ me:\
277 288 :lang=en_US.UTF-8:
278 289 ```
279 290  
280   -- Debian fix: check `locale`...
  291 +* Debian fix: check `locale`...
  292 +
  293 +### The application runs but questions do not show up
  294 +
  295 +Some operating systems have an option to disable animations to try to avoid
  296 +motion sickness in some people. Browsers will check this option with the OS and
  297 +prevent animate.css library to work. Since questions have several animations,
  298 +these will will not work and nothing is shown on the page.
  299 +
  300 +To fix this issue you need to allow animations in the Operating System:
  301 +
  302 +* On windows 10, go to System Preferences, search for "Show animations in
  303 + windows" and turn it **ON**.
  304 +* On MacOS or iOS search for reduced motion and switch it **OFF**
  305 + (Preferences -> Acessibility -> Display -> Reduce motion).
281 306  
282 307 ## FAQ
283 308  
... ... @@ -295,9 +320,10 @@ Some common database queries:
295 320 sqlite3 students.db "select distinct student_id from studenttopic"
296 321  
297 322 # How many topics has each student done?
298   -sqlite3 students.db "select student_id, count(topic_id) from studenttopic group by student_id order by count(topic_id) desc"
  323 +sqlite3 students.db "select student_id, count(topic_id) from studenttopic \
  324 + group by student_id order by count(topic_id) desc"
299 325  
300 326 # Which questions have more wrong answers?
301   -sqlite3 students.db "select count(ref), ref from answers where grade<1.0 group by ref order by count(ref) desc"
  327 +sqlite3 students.db "select count(ref), ref from answers where grade<1.0 \
  328 + group by ref order by count(ref) desc"
302 329 ```
303   -
... ...
aprendizations/__init__.py
... ... @@ -30,10 +30,10 @@ are progressively uncovered as the students progress.
30 30 '''
31 31  
32 32 APP_NAME = 'aprendizations'
33   -APP_VERSION = '2020.01.dev4'
  33 +APP_VERSION = '2021.08.dev1'
34 34 APP_DESCRIPTION = __doc__
35 35  
36 36 __author__ = 'Miguel Barão'
37   -__copyright__ = 'Copyright 2020, Miguel Barão'
  37 +__copyright__ = 'Copyright 2021, Miguel Barão'
38 38 __license__ = 'MIT license'
39 39 __version__ = APP_VERSION
... ...
aprendizations/initdb.py
1 1 #!/usr/bin/env python3
2 2  
  3 +'''
  4 +Initializes or updates database
  5 +'''
  6 +
3 7 # python standard libraries
4 8 import csv
5 9 import argparse
6 10 import re
7 11 from string import capwords
8   -from concurrent.futures import ThreadPoolExecutor
9 12  
10 13 # third party libraries
11 14 import bcrypt
12   -import sqlalchemy as sa
  15 +from sqlalchemy import create_engine, select
  16 +from sqlalchemy.orm import Session
  17 +from sqlalchemy.exc import IntegrityError, NoResultFound
13 18  
14 19 # this project
15   -from .models import Base, Student
  20 +from aprendizations.models import Base, Student
16 21  
17 22  
18 23 # ===========================================================================
19 24 # Parse command line options
20 25 def parse_commandline_arguments():
  26 + '''Parse command line arguments'''
  27 +
21 28 argparser = argparse.ArgumentParser(
22 29 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
23 30 description='Insert new users into a database. Users can be imported '
... ... @@ -65,9 +72,12 @@ def parse_commandline_arguments():
65 72  
66 73  
67 74 # ===========================================================================
68   -# SIIUE names have alien strings like "(TE)" and are sometimes capitalized
69   -# We remove them so that students dont keep asking what it means
70 75 def get_students_from_csv(filename):
  76 + '''Reads CSV file with enrolled students in SIIUE format.
  77 + SIIUE names can have suffixes like "(TE)" and are sometimes capitalized.
  78 + These suffixes are removed.'''
  79 +
  80 + # SIIUE format for CSV files
71 81 csv_settings = {
72 82 'delimiter': ';',
73 83 'quotechar': '"',
... ... @@ -75,8 +85,8 @@ def get_students_from_csv(filename):
75 85 }
76 86  
77 87 try:
78   - with open(filename, encoding='iso-8859-1') as f:
79   - csvreader = csv.DictReader(f, **csv_settings)
  88 + with open(filename, encoding='iso-8859-1') as file:
  89 + csvreader = csv.DictReader(file, **csv_settings)
80 90 students = [{
81 91 'uid': s['N.º'],
82 92 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
... ... @@ -92,105 +102,93 @@ def get_students_from_csv(filename):
92 102  
93 103  
94 104 # ===========================================================================
95   -# replace password by hash for a single student
96   -def hashpw(student, pw=None):
97   - print('.', end='', flush=True)
98   - pw = (pw or student.get('pw', None) or student['uid']).encode('utf-8')
99   - student['pw'] = bcrypt.hashpw(pw, bcrypt.gensalt())
100   -
101   -
102   -# ===========================================================================
103 105 def show_students_in_database(session, verbose=False):
104   - try:
105   - users = session.query(Student).all()
106   - except Exception:
107   - raise
108   - else:
109   - n = len(users)
110   - print(f'\nRegistered users:')
111   - if n == 0:
112   - print(' -- none --')
  106 + '''shows students in the database'''
  107 + users = session.execute(select(Student)).scalars().all()
  108 + total = len(users)
  109 +
  110 + print('\nRegistered users:')
  111 + if users:
  112 + users.sort(key=lambda u: f'{u.id:>12}') # sort by right aligned string
  113 + if verbose:
  114 + for user in users:
  115 + print(f'{user.id:>12} {user.name}')
113 116 else:
114   - users.sort(key=lambda u: f'{u.id:>12}') # sort by number
115   - if verbose:
116   - for u in users:
117   - print(f'{u.id:>12} {u.name}')
118   - else:
119   - print(f'{users[0].id:>12} {users[0].name}')
120   - if n > 1:
121   - print(f'{users[1].id:>12} {users[1].name}')
122   - if n > 3:
123   - print(' | |')
124   - if n > 2:
125   - print(f'{users[-1].id:>12} {users[-1].name}')
126   - print(f'Total: {n}.')
  117 + print(f'{users[0].id:>12} {users[0].name}')
  118 + if total > 1:
  119 + print(f'{users[1].id:>12} {users[1].name}')
  120 + if total > 3:
  121 + print(' | |')
  122 + if total > 2:
  123 + print(f'{users[-1].id:>12} {users[-1].name}')
  124 + print(f'Total: {total}.')
127 125  
128 126  
129 127 # ===========================================================================
130 128 def main():
  129 + '''performs the main functions'''
  130 +
131 131 args = parse_commandline_arguments()
132 132  
133 133 # --- database stuff
134   - print(f'Using database: ', args.db)
135   - engine = sa.create_engine(f'sqlite:///{args.db}', echo=False)
  134 + print(f'Database: {args.db}')
  135 + engine = create_engine(f'sqlite:///{args.db}', echo=False, future=True)
136 136 Base.metadata.create_all(engine) # Creates schema if needed
137   - Session = sa.orm.sessionmaker(bind=engine)
138   - session = Session()
  137 + session = Session(engine, future=True)
139 138  
140   - # --- make list of students to insert/update
  139 + # --- build list of students to insert/update
141 140 students = []
142 141  
143 142 for csvfile in args.csvfile:
144   - # print('Adding users from:', csvfile)
145   - students.extend(get_students_from_csv(csvfile))
  143 + students += get_students_from_csv(csvfile)
146 144  
147 145 if args.admin:
148   - # print('Adding user: 0, Admin.')
149 146 students.append({'uid': '0', 'name': 'Admin'})
150 147  
151 148 if args.add:
152 149 for uid, name in args.add:
153   - # print(f'Adding user: {uid}, {name}.')
154 150 students.append({'uid': uid, 'name': name})
155 151  
156 152 # --- only insert students that are not yet in the database
157   - db_students = {user.id for user in session.query(Student).all()}
158   - new_students = list(filter(lambda s: s['uid'] not in db_students, students))
159   -
160   - if new_students:
161   - # --- password hashing
162   - print(f'Generating password hashes', end='')
163   - with ThreadPoolExecutor() as executor:
164   - executor.map(lambda s: hashpw(s, args.pw), new_students)
165   -
166   - print('\nAdding students:')
167   - for s in new_students:
168   - print(f' + {s["uid"]}, {s["name"]}')
169   -
170   - try:
171   - session.add_all([Student(id=s['uid'],
172   - name=s['name'],
173   - password=s['pw'])
174   - for s in new_students])
175   - session.commit()
176   - except sa.exc.IntegrityError:
177   - print('!!! Integrity error. Aborted !!!\n')
178   - session.rollback()
179   -
180   - print(f'Inserted {len(new_students)} new student(s).')
  153 + print('\nInserting new students:')
  154 +
  155 + db_students = set(session.execute(select(Student.id)).scalars().all())
  156 + new_students = (s for s in students if s['uid'] not in db_students)
  157 + count = 0
  158 + for s in new_students:
  159 + print(f' {s["uid"]}, {s["name"]}')
  160 +
  161 + pw = args.pw or s['uid']
  162 + hashed_pw = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt())
  163 +
  164 + session.add(Student(id=s['uid'], name=s['name'], password=hashed_pw))
  165 + count += 1
  166 +
  167 + try:
  168 + session.commit()
  169 + except IntegrityError:
  170 + print('!!! Integrity error. Aborted !!!\n')
  171 + session.rollback()
181 172 else:
182   - print('There are no new students to add.')
  173 + print(f'Total {count} new student(s).')
183 174  
184 175 # --- update data for student in the database
185   - for s in args.update:
186   - print(f'Updating password of: {s}')
187   - u = session.query(Student).get(s)
188   - if u is not None:
189   - pw = (args.pw or s).encode('utf-8')
190   - u.password = bcrypt.hashpw(pw, bcrypt.gensalt())
191   - session.commit()
192   - else:
193   - print(f'!!! Student {s} does not exist. Skipping update !!!')
  176 + if args.update:
  177 + print('\nUpdating passwords of students:')
  178 + count = 0
  179 + for sid in args.update:
  180 + try:
  181 + s = session.execute(select(Student).filter_by(id=sid)).scalar_one()
  182 + except NoResultFound:
  183 + print(f' -> student {sid} does not exist!')
  184 + continue
  185 + else:
  186 + print(f' {sid}, {s.name}')
  187 + count += 1
  188 + pw = args.pw or sid
  189 + s.password = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt())
  190 + session.commit()
  191 + print(f'Total {count} password(s) updated.')
194 192  
195 193 show_students_in_database(session, args.verbose)
196 194  
... ...
aprendizations/learnapp.py
  1 +'''
  2 +Learn application.
  3 +This is the main controller of the application.
  4 +'''
1 5  
2 6 # python standard library
3 7 import asyncio
4 8 from collections import defaultdict
5   -from contextlib import contextmanager # `with` statement in db sessions
  9 +# from contextlib import contextmanager # `with` statement in db sessions
6 10 from datetime import datetime
7 11 import logging
8 12 from random import random
9   -from os import path
  13 +from os.path import join, exists
10 14 from typing import Any, Dict, Iterable, List, Optional, Tuple, Set, DefaultDict
11 15  
12 16 # third party libraries
13 17 import bcrypt
14 18 import networkx as nx
15   -import sqlalchemy as sa
  19 +from sqlalchemy import create_engine, select, func
  20 +from sqlalchemy.orm import Session
  21 +from sqlalchemy.exc import NoResultFound
16 22  
17 23 # this project
18   -from .models import Student, Answer, Topic, StudentTopic
19   -from .questions import Question, QFactory, QDict, QuestionException
20   -from .student import StudentState
21   -from .tools import load_yaml
  24 +from aprendizations.models import Student, Answer, Topic, StudentTopic
  25 +from aprendizations.questions import Question, QFactory, QDict, QuestionException
  26 +from aprendizations.student import StudentState
  27 +from aprendizations.tools import load_yaml
22 28  
23 29  
24 30 # setup logger for this module
... ... @@ -27,185 +33,174 @@ logger = logging.getLogger(__name__)
27 33  
28 34 # ============================================================================
29 35 class LearnException(Exception):
30   - pass
  36 + '''Exceptions raised from the LearnApp class'''
31 37  
32 38  
33 39 class DatabaseUnusableError(LearnException):
34   - pass
  40 + '''Exception raised if the database fails in the initialization'''
35 41  
36 42  
37 43 # ============================================================================
38   -# LearnApp - application logic
39   -#
40   -# self.deps - networkx topic dependencies
41   -# self.courses - dict {course_id: {'title': ...,
42   -# 'description': ...,
43   -# 'goals': ...,}, ...}
44   -# self.factory = dict {qref: QFactory()}
45   -# self.online - dict {student_id: {'number': ...,
46   -# 'name': ...,
47   -# 'state': StudentState(),
48   -# 'counter': ...}, ...}
49   -# ============================================================================
50   -class LearnApp(object):
51   - # ------------------------------------------------------------------------
52   - # helper to manage db sessions using the `with` statement, for example
53   - # with self.db_session() as s: s.query(...)
54   - # ------------------------------------------------------------------------
55   - @contextmanager
56   - def db_session(self, **kw):
57   - session = self.Session(**kw)
58   - try:
59   - yield session
60   - session.commit()
61   - except Exception:
62   - logger.error('!!! Database rollback !!!')
63   - session.rollback()
64   - raise
65   - finally:
66   - session.close()
  44 +class LearnApp():
  45 + '''
  46 + LearnApp - application logic
  47 +
  48 + self.deps - networkx topic dependencies
  49 + self.courses - dict {course_id: {'title': ...,
  50 + 'description': ...,
  51 + 'goals': ...,}, ...}
  52 + self.factory = dict {qref: QFactory()}
  53 + self.online - dict {student_id: {'number': ...,
  54 + 'name': ...,
  55 + 'state': StudentState(),
  56 + 'counter': ...}, ...}
  57 + '''
67 58  
68 59 # ------------------------------------------------------------------------
69   - # init
70   - # ------------------------------------------------------------------------
71 60 def __init__(self,
72 61 courses: str, # filename with course configurations
73 62 prefix: str, # path to topics
74 63 db: str, # database filename
75 64 check: bool = False) -> None:
76 65  
77   - self.db_setup(db) # setup database and check students
  66 + self._db_setup(db) # setup database and check students
78 67 self.online: Dict[str, Dict] = dict() # online students
79 68  
80 69 try:
81 70 config: Dict[str, Any] = load_yaml(courses)
82   - except Exception:
  71 + except Exception as exc:
83 72 msg = f'Failed to load yaml file "{courses}"'
84 73 logger.error(msg)
85   - raise LearnException(msg)
  74 + raise LearnException(msg) from exc
86 75  
87 76 # --- topic dependencies are shared between all courses
88 77 self.deps = nx.DiGraph(prefix=prefix)
89 78 logger.info('Populating topic graph:')
90 79  
91   - t = config.get('topics', {}) # topics defined directly in courses file
92   - self.populate_graph(t)
93   - logger.info(f'{len(t):>6} topics in {courses}')
94   - for f in config.get('topics_from', []):
95   - c = load_yaml(f) # course configuration
  80 + # topics defined directly in the courses file, usually empty
  81 + base_topics = config.get('topics', {})
  82 + self._populate_graph(base_topics)
  83 + logger.info('%6d topics in %s', len(base_topics), courses)
  84 +
  85 + # load other course files with the topics the their deps
  86 + for course_file in config.get('topics_from', []):
  87 + course_conf = load_yaml(course_file) # course configuration
96 88 # FIXME set defaults??
97   - logger.info(f'{len(c["topics"]):>6} topics imported from {f}')
98   - self.populate_graph(c)
99   - logger.info(f'Graph has {len(self.deps)} topics')
  89 + logger.info('%6d topics imported from %s',
  90 + len(course_conf["topics"]), course_file)
  91 + self._populate_graph(course_conf)
  92 + logger.info('Graph has %d topics', len(self.deps))
100 93  
101 94 # --- courses dict
102 95 self.courses = config['courses']
103   - logger.info(f'Courses: {", ".join(self.courses.keys())}')
104   - for c, d in self.courses.items():
105   - d.setdefault('title', '') # course title undefined
106   - for goal in d['goals']:
  96 + logger.info('Courses: %s', ', '.join(self.courses.keys()))
  97 + for cid, course in self.courses.items():
  98 + course.setdefault('title', cid) # course title undefined
  99 + for goal in course['goals']:
107 100 if goal not in self.deps.nodes():
108   - msg = f'Goal "{goal}" from course "{c}" does not exist'
  101 + msg = f'Goal "{goal}" from course "{cid}" does not exist'
109 102 logger.error(msg)
110 103 raise LearnException(msg)
111   - elif self.deps.nodes[goal]['type'] == 'chapter':
112   - d['goals'] += [g for g in self.deps.predecessors(goal)
113   - if g not in d['goals']]
  104 + if self.deps.nodes[goal]['type'] == 'chapter':
  105 + course['goals'] += [g for g in self.deps.predecessors(goal)
  106 + if g not in course['goals']]
114 107  
115 108 # --- factory is a dict with question generators for all topics
116   - self.factory: Dict[str, QFactory] = self.make_factory()
  109 + self.factory: Dict[str, QFactory] = self._make_factory()
117 110  
118 111 # if graph has topics that are not in the database, add them
119   - self.add_missing_topics(self.deps.nodes())
  112 + self._add_missing_topics(self.deps.nodes())
120 113  
121 114 if check:
122   - self.sanity_check_questions()
  115 + self._sanity_check_questions()
123 116  
124 117 # ------------------------------------------------------------------------
125   - def sanity_check_questions(self) -> None:
  118 + def _sanity_check_questions(self) -> None:
  119 + '''
  120 + Unit tests for all questions
  121 +
  122 + Generates all questions, give right and wrong answers and corrects.
  123 + '''
126 124 logger.info('Starting sanity checks (may take a while...)')
127 125  
128 126 errors: int = 0
129 127 for qref in self.factory:
130   - logger.debug(f'checking {qref}...')
  128 + logger.debug('checking %s...', qref)
131 129 try:
132   - q = self.factory[qref].generate()
133   - except QuestionException as e:
134   - logger.error(e)
  130 + question = self.factory[qref].generate()
  131 + except QuestionException as exc:
  132 + logger.error(exc)
135 133 errors += 1
136 134 continue # to next question
137 135  
138   - if 'tests_right' in q:
139   - for t in q['tests_right']:
140   - q['answer'] = t
141   - q.correct()
142   - if q['grade'] < 1.0:
143   - logger.error(f'Failed right answer in "{qref}".')
  136 + if 'tests_right' in question:
  137 + for right_answer in question['tests_right']:
  138 + question['answer'] = right_answer
  139 + question.correct()
  140 + if question['grade'] < 1.0:
  141 + logger.error('Failed right answer in "%s".', qref)
144 142 errors += 1
145 143 continue # to next test
146   -
147   - if 'tests_wrong' in q:
148   - for t in q['tests_wrong']:
149   - q['answer'] = t
150   - q.correct()
151   - if q['grade'] >= 1.0:
152   - logger.error(f'Failed wrong answer in "{qref}".')
  144 + elif question['type'] == 'textarea':
  145 + msg = f'- consider adding tests to {question["ref"]}'
  146 + logger.warning(msg)
  147 +
  148 + if 'tests_wrong' in question:
  149 + for wrong_answer in question['tests_wrong']:
  150 + question['answer'] = wrong_answer
  151 + question.correct()
  152 + if question['grade'] >= 1.0:
  153 + logger.error('Failed wrong answer in "%s".', qref)
153 154 errors += 1
154 155 continue # to next test
155 156  
156 157 if errors > 0:
157   - logger.error(f'{errors:>6} error(s) found.')
  158 + logger.error('%6d error(s) found.', errors)
158 159 raise LearnException('Sanity checks')
159   - else:
160   - logger.info(' 0 errors found.')
  160 + logger.info(' 0 errors found.')
161 161  
162 162 # ------------------------------------------------------------------------
163   - # login
164   - # ------------------------------------------------------------------------
165   - async def login(self, uid: str, pw: str) -> bool:
166   -
167   - with self.db_session() as s:
168   - found = s.query(Student.name, Student.password) \
169   - .filter_by(id=uid) \
170   - .one_or_none()
  163 + async def login(self, uid: str, password: str) -> bool:
  164 + '''user login'''
171 165  
172 166 # wait random time to minimize timing attacks
173 167 await asyncio.sleep(random())
174 168  
175   - loop = asyncio.get_running_loop()
176   - if found is None:
177   - logger.info(f'User "{uid}" does not exist')
178   - await loop.run_in_executor(None, bcrypt.hashpw, b'',
179   - bcrypt.gensalt()) # just spend time
  169 + query = select(Student).where(Student.id == uid)
  170 + try:
  171 + with Session(self._engine, future=True) as session:
  172 + student = session.execute(query).scalar_one()
  173 + except NoResultFound:
  174 + logger.info('User "%s" does not exist', uid)
180 175 return False
181 176  
182   - else:
183   - name, hashed_pw = found
184   - pw_ok: bool = await loop.run_in_executor(None,
185   - bcrypt.checkpw,
186   - pw.encode('utf-8'),
187   - hashed_pw)
  177 + loop = asyncio.get_running_loop()
  178 + pw_ok: bool = await loop.run_in_executor(None,
  179 + bcrypt.checkpw,
  180 + password.encode('utf-8'),
  181 + student.password)
188 182  
189 183 if pw_ok:
190 184 if uid in self.online:
191   - logger.warning(f'User "{uid}" already logged in')
  185 + logger.warning('User "%s" already logged in', uid)
192 186 counter = self.online[uid]['counter']
193 187 else:
194   - logger.info(f'User "{uid}" logged in')
  188 + logger.info('User "%s" logged in', uid)
195 189 counter = 0
196 190  
197   - # get topics of this student and set its current state
198   - with self.db_session() as s:
199   - tt = s.query(StudentTopic).filter_by(student_id=uid)
  191 + # get topics for this student and set its current state
  192 + query = select(StudentTopic).where(StudentTopic.student_id == uid)
  193 + with Session(self._engine, future=True) as session:
  194 + student_topics = session.execute(query).scalars().all()
200 195  
201 196 state = {t.topic_id: {
202 197 'level': t.level,
203 198 'date': datetime.strptime(t.date, "%Y-%m-%d %H:%M:%S.%f")
204   - } for t in tt}
  199 + } for t in student_topics}
205 200  
206 201 self.online[uid] = {
207 202 'number': uid,
208   - 'name': name,
  203 + 'name': student.name,
209 204 'state': StudentState(uid=uid, state=state,
210 205 courses=self.courses, deps=self.deps,
211 206 factory=self.factory),
... ... @@ -213,178 +208,208 @@ class LearnApp(object):
213 208 }
214 209  
215 210 else:
216   - logger.info(f'User "{uid}" wrong password')
  211 + logger.info('User "%s" wrong password', uid)
217 212  
218 213 return pw_ok
219 214  
220 215 # ------------------------------------------------------------------------
221   - # logout
222   - # ------------------------------------------------------------------------
223 216 def logout(self, uid: str) -> None:
  217 + '''User logout'''
224 218 del self.online[uid]
225   - logger.info(f'User "{uid}" logged out')
  219 + logger.info('User "%s" logged out', uid)
226 220  
227 221 # ------------------------------------------------------------------------
228   - # change_password. returns True if password is successfully changed.
229   - # ------------------------------------------------------------------------
230   - async def change_password(self, uid: str, pw: str) -> bool:
231   - if not pw:
  222 + async def change_password(self, uid: str, password: str) -> bool:
  223 + '''
  224 + Change user Password.
  225 + Returns True if password is successfully changed
  226 + '''
  227 + if not password:
232 228 return False
233 229  
234 230 loop = asyncio.get_running_loop()
235   - pw = await loop.run_in_executor(None, bcrypt.hashpw,
236   - pw.encode('utf-8'), bcrypt.gensalt())
237   -
238   - with self.db_session() as s:
239   - u = s.query(Student).get(uid)
240   - u.password = pw
  231 + hashed_pw = await loop.run_in_executor(None,
  232 + bcrypt.hashpw,
  233 + password.encode('utf-8'),
  234 + bcrypt.gensalt())
  235 +
  236 + with Session(self._engine, future=True) as session:
  237 + query = select(Student).where(Student.id == uid)
  238 + user = session.execute(query).scalar_one()
  239 + user.password = hashed_pw
  240 + session.commit()
241 241  
242   - logger.info(f'User "{uid}" changed password')
  242 + logger.info('User "%s" changed password', uid)
243 243 return True
244 244  
245 245 # ------------------------------------------------------------------------
246   - # Checks answer and update database. Returns corrected question.
247   - # ------------------------------------------------------------------------
248 246 async def check_answer(self, uid: str, answer) -> Question:
  247 + '''
  248 + Checks answer and update database.
  249 + Returns corrected question.
  250 + '''
249 251 student = self.online[uid]['state']
250 252 await student.check_answer(answer)
251   - q: Question = student.get_current_question()
252 253  
253   - logger.info(f'User "{uid}" got {q["grade"]:.2} in "{q["ref"]}"')
  254 + topic_id = student.get_current_topic()
  255 + question: Question = student.get_current_question()
  256 + grade = question["grade"]
  257 + ref = question["ref"]
  258 +
  259 + logger.info('User "%s" got %.2f in "%s"', uid, grade, ref)
254 260  
255 261 # always save grade of answered question
256   - with self.db_session() as s:
257   - s.add(Answer(
258   - ref=q['ref'],
259   - grade=q['grade'],
260   - starttime=str(q['start_time']),
261   - finishtime=str(q['finish_time']),
262   - student_id=uid,
263   - topic_id=student.get_current_topic()))
  262 + answer = Answer(ref=ref,
  263 + grade=grade,
  264 + starttime=str(question['start_time']),
  265 + finishtime=str(question['finish_time']),
  266 + student_id=uid,
  267 + topic_id=topic_id)
  268 + with Session(self._engine, future=True) as session:
  269 + session.add(answer)
  270 + session.commit()
264 271  
265   - return q
  272 + return question
266 273  
267 274 # ------------------------------------------------------------------------
268   - # get the question to show (current or new one)
269   - # if no more questions, save/update level in database
270   - # ------------------------------------------------------------------------
271 275 async def get_question(self, uid: str) -> Optional[Question]:
272   - student = self.online[uid]['state']
273   - q: Optional[Question] = await student.get_question()
  276 + '''
  277 + Get the question to show (current or new one)
  278 + If no more questions, save/update level in database
  279 + '''
  280 + student_state = self.online[uid]['state']
  281 + question: Optional[Question] = await student_state.get_question()
274 282  
275 283 # save topic to database if finished
276   - if student.topic_has_finished():
277   - topic: str = student.get_previous_topic()
278   - level: float = student.get_topic_level(topic)
279   - date: str = str(student.get_topic_date(topic))
280   - logger.info(f'User "{uid}" finished "{topic}" (level={level:.2})')
281   -
282   - with self.db_session() as s:
283   - a = s.query(StudentTopic) \
284   - .filter_by(student_id=uid, topic_id=topic) \
285   - .one_or_none()
286   - if a is None:
  284 + if student_state.topic_has_finished():
  285 + topic_id: str = student_state.get_previous_topic()
  286 + level: float = student_state.get_topic_level(topic_id)
  287 + date: str = str(student_state.get_topic_date(topic_id))
  288 + logger.info('User "%s" finished "%s" (level=%.2f)',
  289 + uid, topic_id, level)
  290 +
  291 + query = select(StudentTopic).where(StudentTopic.student_id == uid).where(StudentTopic.topic_id == topic_id)
  292 + with Session(self._engine, future=True) as session:
  293 + student_topic = session.execute(query).scalar_one_or_none()
  294 +
  295 + if student_topic is None:
287 296 # insert new studenttopic into database
288 297 logger.debug('db insert studenttopic')
289   - t = s.query(Topic).get(topic)
290   - u = s.query(Student).get(uid)
  298 + query_topic = select(Topic).where(Topic.id == topic_id)
  299 + query_student = select(Student).where(Student.id == uid)
  300 + topic = session.execute(query_topic).scalar_one()
  301 + student = session.execute(query_student).scalar_one()
291 302 # association object
292   - a = StudentTopic(level=level, date=date, topic=t,
293   - student=u)
294   - u.topics.append(a)
  303 + student_topic = StudentTopic(level=level,
  304 + date=date,
  305 + topic=topic,
  306 + student=student)
  307 + student.topics.append(student_topic)
295 308 else:
296 309 # update studenttopic in database
297   - logger.debug(f'db update studenttopic to level {level}')
298   - a.level = level
299   - a.date = date
  310 + logger.debug('db update studenttopic to level %f', level)
  311 + student_topic.level = level
  312 + student_topic.date = date
300 313  
301   - s.add(a)
  314 + session.add(student_topic)
  315 + session.commit()
302 316  
303   - return q
  317 + return question
304 318  
305 319 # ------------------------------------------------------------------------
306   - # Start course
307   - # ------------------------------------------------------------------------
308 320 def start_course(self, uid: str, course_id: str) -> None:
309   - student = self.online[uid]['state']
  321 + '''Start course'''
  322 +
  323 + student_state = self.online[uid]['state']
310 324 try:
311   - student.start_course(course_id)
312   - except Exception:
313   - logger.warning(f'"{uid}" could not start course "{course_id}"')
314   - raise
  325 + student_state.start_course(course_id)
  326 + except Exception as exc:
  327 + logger.warning('"%s" could not start course "%s"', uid, course_id)
  328 + raise LearnException() from exc
315 329 else:
316   - logger.info(f'User "{uid}" started course "{course_id}"')
  330 + logger.info('User "%s" started course "%s"', uid, course_id)
317 331  
318 332 # ------------------------------------------------------------------------
319   - # Start new topic
  333 + #
320 334 # ------------------------------------------------------------------------
321 335 async def start_topic(self, uid: str, topic: str) -> None:
  336 + '''Start new topic'''
  337 +
322 338 student = self.online[uid]['state']
323   - if uid == '0':
324   - logger.warning(f'Reloading "{topic}"') # FIXME should be an option
325   - self.factory.update(self.factory_for(topic))
  339 + # if uid == '0':
  340 + # logger.warning('Reloading "%s"', topic) # FIXME should be an option
  341 + # self.factory.update(self._factory_for(topic))
326 342  
327 343 try:
328 344 await student.start_topic(topic)
329   - except Exception as e:
330   - logger.warning(f'User "{uid}" could not start "{topic}": {e}')
  345 + except Exception as exc:
  346 + logger.warning('User "%s" could not start "%s": %s',
  347 + uid, topic, str(exc))
331 348 else:
332   - logger.info(f'User "{uid}" started topic "{topic}"')
333   -
334   - # ------------------------------------------------------------------------
335   - # Fill db table 'Topic' with topics from the graph if not already there.
336   - # ------------------------------------------------------------------------
337   - def add_missing_topics(self, topics: List[str]) -> None:
338   - with self.db_session() as s:
339   - new_topics = [Topic(id=t) for t in topics
340   - if (t,) not in s.query(Topic.id)]
341   -
342   - if new_topics:
343   - s.add_all(new_topics)
344   - logger.info(f'Added {len(new_topics)} new topic(s) to the '
345   - f'database')
  349 + logger.info('User "%s" started topic "%s"', uid, topic)
346 350  
347 351 # ------------------------------------------------------------------------
348   - # setup and check database contents
  352 + #
349 353 # ------------------------------------------------------------------------
350   - def db_setup(self, db: str) -> None:
351   -
352   - logger.info(f'Checking database "{db}":')
353   - if not path.exists(db):
  354 + def _add_missing_topics(self, topics: Iterable[str]) -> None:
  355 + '''
  356 + Fill db table 'Topic' with topics from the graph, if new
  357 + '''
  358 + with Session(self._engine, future=True) as session:
  359 + db_topics = session.execute(select(Topic.id)).scalars().all()
  360 + new = [Topic(id=t) for t in topics if t not in db_topics]
  361 + if new:
  362 + session.add_all(new)
  363 + session.commit()
  364 + logger.info('Added %d new topic(s) to the database', len(new))
  365 +
  366 + # ------------------------------------------------------------------------
  367 + def _db_setup(self, database: str) -> None:
  368 + '''
  369 + Setup and check database contents
  370 + '''
  371 +
  372 + logger.info('Checking database "%s":', database)
  373 + if not exists(database):
354 374 raise LearnException('Database does not exist. '
355 375 'Use "initdb-aprendizations" to create')
356 376  
357   - engine = sa.create_engine(f'sqlite:///{db}', echo=False)
358   - self.Session = sa.orm.sessionmaker(bind=engine)
  377 + self._engine = create_engine(f'sqlite:///{database}', future=True)
  378 +
  379 +
359 380 try:
360   - with self.db_session() as s:
361   - n: int = s.query(Student).count()
362   - m: int = s.query(Topic).count()
363   - q: int = s.query(Answer).count()
364   - except Exception:
365   - logger.error(f'Database "{db}" not usable!')
366   - raise DatabaseUnusableError()
  381 + query_students = select(func.count(Student.id))
  382 + query_topics = select(func.count(Topic.id))
  383 + query_answers = select(func.count(Answer.id))
  384 + with Session(self._engine, future=True) as session:
  385 + count_students = session.execute(query_students).scalar()
  386 + count_topics = session.execute(query_topics).scalar()
  387 + count_answers = session.execute(query_answers).scalar()
  388 + except Exception as exc:
  389 + logger.error('Database "%s" not usable!', database)
  390 + raise DatabaseUnusableError() from exc
367 391 else:
368   - logger.info(f'{n:6} students')
369   - logger.info(f'{m:6} topics')
370   - logger.info(f'{q:6} answers')
  392 + logger.info('%6d students', count_students)
  393 + logger.info('%6d topics', count_topics)
  394 + logger.info('%6d answers', count_answers)
371 395  
372   - # ========================================================================
373   - # Populates a digraph.
374   - #
375   - # Nodes are the topic references e.g. 'my/topic'
376   - # g.nodes['my/topic']['name'] name of the topic
377   - # g.nodes['my/topic']['questions'] list of question refs
378   - #
379   - # Edges are obtained from the deps defined in the YAML file for each topic.
380 396 # ------------------------------------------------------------------------
381   - def populate_graph(self, config: Dict[str, Any]) -> None:
382   - g = self.deps # dependency graph
  397 + def _populate_graph(self, config: Dict[str, Any]) -> None:
  398 + '''
  399 + Populates a digraph.
  400 +
  401 + Nodes are the topic references e.g. 'my/topic'
  402 + g.nodes['my/topic']['name'] name of the topic
  403 + g.nodes['my/topic']['questions'] list of question refs
  404 +
  405 + Edges are obtained from the deps defined in the YAML file for each topic.
  406 + '''
  407 +
383 408 defaults = {
384   - 'type': 'topic',
  409 + 'type': 'topic', # chapter
385 410 'file': 'questions.yaml',
386 411 'shuffle_questions': True,
387   - 'choose': 9999,
  412 + 'choose': 99,
388 413 'forgetting_factor': 1.0, # no forgetting
389 414 'max_tries': 1, # in every question
390 415 'append_wrong': True,
... ... @@ -394,20 +419,21 @@ class LearnApp(object):
394 419  
395 420 # iterate over topics and populate graph
396 421 topics: Dict[str, Dict] = config.get('topics', {})
397   - g.add_nodes_from(topics.keys())
  422 + self.deps.add_nodes_from(topics.keys())
398 423 for tref, attr in topics.items():
399   - logger.debug(f' + {tref}')
400   - for d in attr.get('deps', []):
401   - g.add_edge(d, tref)
  424 + logger.debug(' + %s', tref)
  425 + for dep in attr.get('deps', []):
  426 + self.deps.add_edge(dep, tref)
402 427  
403   - t = g.nodes[tref] # get current topic node
404   - t['name'] = attr.get('name', tref)
405   - t['questions'] = attr.get('questions', [])
  428 + topic = self.deps.nodes[tref] # get current topic node
  429 + topic['name'] = attr.get('name', tref)
  430 + topic['questions'] = attr.get('questions', []) # FIXME unused??
406 431  
407 432 for k, default in defaults.items():
408   - t[k] = attr.get(k, default)
  433 + topic[k] = attr.get(k, default)
409 434  
410   - t['path'] = path.join(g.graph['prefix'], tref) # prefix/topic
  435 + # prefix/topic
  436 + topic['path'] = join(self.deps.graph['prefix'], tref)
411 437  
412 438  
413 439 # ========================================================================
... ... @@ -415,48 +441,45 @@ class LearnApp(object):
415 441 # ========================================================================
416 442  
417 443 # ------------------------------------------------------------------------
418   - # Buils dictionary of question factories
419   - # - visits each topic in the graph,
420   - # - adds factory for each topic.
421   - # ------------------------------------------------------------------------
422   - def make_factory(self) -> Dict[str, QFactory]:
  444 + def _make_factory(self) -> Dict[str, QFactory]:
  445 + '''
  446 + Buils dictionary of question factories
  447 + - visits each topic in the graph,
  448 + - adds factory for each topic.
  449 + '''
423 450  
424 451 logger.info('Building questions factory:')
425 452 factory = dict()
426   - g = self.deps
427   - for tref in g.nodes():
428   - factory.update(self.factory_for(tref))
  453 + for tref in self.deps.nodes:
  454 + factory.update(self._factory_for(tref))
429 455  
430   - logger.info(f'Factory has {len(factory)} questions')
  456 + logger.info('Factory has %s questions', len(factory))
431 457 return factory
432 458  
433 459 # ------------------------------------------------------------------------
434 460 # makes factory for a single topic
435 461 # ------------------------------------------------------------------------
436   - def factory_for(self, tref: str) -> Dict[str, QFactory]:
  462 + def _factory_for(self, tref: str) -> Dict[str, QFactory]:
437 463 factory: Dict[str, QFactory] = dict()
438   - g = self.deps
439   - t = g.nodes[tref] # get node
  464 + topic = self.deps.nodes[tref] # get node
440 465 # load questions as list of dicts
441   - topicpath: str = path.join(g.graph['prefix'], tref)
442 466 try:
443   - fullpath: str = path.join(topicpath, t['file'])
444   - except Exception:
445   - msg1 = f'Invalid topic "{tref}"'
446   - msg2 = f'Check dependencies of: {", ".join(g.successors(tref))}'
447   - msg = f'{msg1}. {msg2}'
  467 + fullpath: str = join(topic['path'], topic['file'])
  468 + except Exception as exc:
  469 + msg = f'Invalid topic "{tref}". Check dependencies of: ' + \
  470 + ', '.join(self.deps.successors(tref))
448 471 logger.error(msg)
449   - raise LearnException(msg)
450   - logger.debug(f' Loading {fullpath}')
  472 + raise LearnException(msg) from exc
  473 + logger.debug(' Loading %s', fullpath)
451 474 try:
452 475 questions: List[QDict] = load_yaml(fullpath)
453   - except Exception:
454   - if t['type'] == 'chapter':
  476 + except Exception as exc:
  477 + if topic['type'] == 'chapter':
455 478 return factory # chapters may have no "questions"
456   - else:
457   - msg = f'Failed to load "{fullpath}"'
458   - logger.error(msg)
459   - raise LearnException(msg)
  479 + msg = f'Failed to load "{fullpath}"'
  480 + logger.error(msg)
  481 + raise LearnException(msg) from exc
  482 +
460 483 if not isinstance(questions, list):
461 484 msg = f'File "{fullpath}" must be a list of questions'
462 485 logger.error(msg)
... ... @@ -467,134 +490,161 @@ class LearnApp(object):
467 490 # undefined are set to topic:n, where n is the question number
468 491 # within the file
469 492 localrefs: Set[str] = set() # refs in current file
470   - for i, q in enumerate(questions):
471   - qref = q.get('ref', str(i)) # ref or number
  493 + for i, question in enumerate(questions):
  494 + qref = question.get('ref', str(i)) # ref or number
472 495 if qref in localrefs:
473   - msg = f'Duplicate ref "{qref}" in "{topicpath}"'
  496 + msg = f'Duplicate ref "{qref}" in "{topic["path"]}"'
  497 + logger.error(msg)
474 498 raise LearnException(msg)
475 499 localrefs.add(qref)
476 500  
477   - q['ref'] = f'{tref}:{qref}'
478   - q['path'] = topicpath
479   - q.setdefault('append_wrong', t['append_wrong'])
  501 + question['ref'] = f'{tref}:{qref}'
  502 + question['path'] = topic['path']
  503 + question.setdefault('append_wrong', topic['append_wrong'])
480 504  
481 505 # if questions are left undefined, include all.
482   - if not t['questions']:
483   - t['questions'] = [q['ref'] for q in questions]
  506 + if not topic['questions']:
  507 + topic['questions'] = [q['ref'] for q in questions]
484 508  
485   - t['choose'] = min(t['choose'], len(t['questions']))
  509 + topic['choose'] = min(topic['choose'], len(topic['questions']))
486 510  
487   - for q in questions:
488   - if q['ref'] in t['questions']:
489   - factory[q['ref']] = QFactory(q)
490   - logger.debug(f' + {q["ref"]}')
  511 + for question in questions:
  512 + if question['ref'] in topic['questions']:
  513 + factory[question['ref']] = QFactory(question)
  514 + logger.debug(' + %s', question["ref"])
491 515  
492   - logger.info(f'{len(t["questions"]):6} questions in {tref}')
  516 + logger.info('%6d questions in %s', len(topic["questions"]), tref)
493 517  
494 518 return factory
495 519  
496 520 # ------------------------------------------------------------------------
497 521 def get_login_counter(self, uid: str) -> int:
  522 + '''login counter''' # FIXME
498 523 return int(self.online[uid]['counter'])
499 524  
500 525 # ------------------------------------------------------------------------
501 526 def get_student_name(self, uid: str) -> str:
  527 + '''Get the username'''
502 528 return self.online[uid].get('name', '')
503 529  
504 530 # ------------------------------------------------------------------------
505 531 def get_student_state(self, uid: str) -> List[Dict[str, Any]]:
  532 + '''Get the knowledge state of a given user'''
506 533 return self.online[uid]['state'].get_knowledge_state()
507 534  
508 535 # ------------------------------------------------------------------------
509 536 def get_student_progress(self, uid: str) -> float:
  537 + '''Get the current topic progress of a given user'''
510 538 return float(self.online[uid]['state'].get_topic_progress())
511 539  
512 540 # ------------------------------------------------------------------------
513 541 def get_current_question(self, uid: str) -> Optional[Question]:
514   - q: Optional[Question] = self.online[uid]['state'].get_current_question()
515   - return q
  542 + '''Get the current question of a given user'''
  543 + question: Optional[Question] = self.online[uid]['state'].get_current_question()
  544 + return question
516 545  
517 546 # ------------------------------------------------------------------------
518 547 def get_current_question_id(self, uid: str) -> str:
  548 + '''Get id of the current question for a given user'''
519 549 return str(self.online[uid]['state'].get_current_question()['qid'])
520 550  
521 551 # ------------------------------------------------------------------------
522 552 def get_student_question_type(self, uid: str) -> str:
  553 + '''Get type of the current question for a given user'''
523 554 return str(self.online[uid]['state'].get_current_question()['type'])
524 555  
525 556 # ------------------------------------------------------------------------
526   - def get_student_topic(self, uid: str) -> str:
527   - return str(self.online[uid]['state'].get_current_topic())
  557 + # def get_student_topic(self, uid: str) -> str:
  558 + # return str(self.online[uid]['state'].get_current_topic())
528 559  
529 560 # ------------------------------------------------------------------------
530 561 def get_student_course_title(self, uid: str) -> str:
  562 + '''get the title of the current course for a given user'''
531 563 return str(self.online[uid]['state'].get_current_course_title())
532 564  
533 565 # ------------------------------------------------------------------------
534 566 def get_current_course_id(self, uid: str) -> Optional[str]:
  567 + '''get the current course (id) of a given user'''
535 568 cid: Optional[str] = self.online[uid]['state'].get_current_course_id()
536 569 return cid
537 570  
538 571 # ------------------------------------------------------------------------
539   - def get_topic_name(self, ref: str) -> str:
540   - return str(self.deps.nodes[ref]['name'])
  572 + # def get_topic_name(self, ref: str) -> str:
  573 + # return str(self.deps.nodes[ref]['name'])
541 574  
542 575 # ------------------------------------------------------------------------
543 576 def get_current_public_dir(self, uid: str) -> str:
  577 + '''
  578 + Get the path for the 'public' directory of the current topic of the
  579 + given user.
  580 + E.g. if the user has the active topic 'xpto',
  581 + then returns 'path/to/xpto/public'.
  582 + '''
544 583 topic: str = self.online[uid]['state'].get_current_topic()
545 584 prefix: str = self.deps.graph['prefix']
546   - return path.join(prefix, topic, 'public')
  585 + return join(prefix, topic, 'public')
547 586  
548 587 # ------------------------------------------------------------------------
549 588 def get_courses(self) -> Dict[str, Dict[str, Any]]:
  589 + '''
  590 + Get dictionary with all courses {'course1': {...}, 'course2': {...}}
  591 + '''
550 592 return self.courses
551 593  
552 594 # ------------------------------------------------------------------------
553 595 def get_course(self, course_id: str) -> Dict[str, Any]:
  596 + '''
  597 + Get dictionary {'title': ..., 'description':..., 'goals':...}
  598 + '''
554 599 return self.courses[course_id]
555 600  
556 601 # ------------------------------------------------------------------------
557 602 def get_rankings(self, uid: str, course_id: str) -> Iterable[Tuple[str, str, float, float]]:
558   -
559   - logger.info(f'User "{uid}" get rankings for {course_id}')
560   - with self.db_session() as s:
561   - students = s.query(Student.id, Student.name).all()
562   -
563   - # topic progress
564   - student_topics = s.query(StudentTopic.student_id,
565   - StudentTopic.topic_id,
566   - StudentTopic.level,
567   - StudentTopic.date).all()
  603 + '''
  604 + Returns rankings for a certain course_id.
  605 + User where uid have <=2 chars are considered ghosts are hidden from
  606 + the rankings. This is so that there can be users for development or
  607 + testing purposes, which are not real users.
  608 + The user_id of real students must have >2 chars.
  609 + This should be modified to have a "visible" flag
  610 + '''
  611 +
  612 + logger.info('User "%s" get rankings for %s', uid, course_id)
  613 + query_students = select(Student.id, Student.name)
  614 + query_student_topics = select(StudentTopic.student_id,
  615 + StudentTopic.topic_id,
  616 + StudentTopic.level,
  617 + StudentTopic.date)
  618 + query_total = select(Answer.student_id, func.count(Answer.ref))
  619 + query_right = select(Answer.student_id, func.count(Answer.ref)).where(Answer.grade == 1.0)
  620 + with Session(self._engine, future=True) as session:
  621 + # all students in the database FIXME only with answers of this course
  622 + students = session.execute(query_students).all()
  623 +
  624 + # topic levels FIXME only topics of this course
  625 + student_topics = session.execute(query_student_topics).all()
568 626  
569 627 # answer performance
570   - total = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)).
571   - group_by(Answer.student_id).
572   - all())
573   - right = dict(s.query(Answer.student_id, sa.func.count(Answer.ref)).
574   - filter(Answer.grade == 1.0).
575   - group_by(Answer.student_id).
576   - all())
  628 + total = dict(session.execute(query_total).all())
  629 + right = dict(session.execute(query_right).all())
577 630  
578 631 # compute percentage of right answers
579   - perf: Dict[str, float] = {u: right.get(u, 0.0)/total[u]
  632 + perf: Dict[str, float] = {u: right.get(u, 0.0) / total[u]
580 633 for u in total}
581 634  
582 635 # compute topic progress
583 636 now = datetime.now()
584 637 goals = self.courses[course_id]['goals']
585   - prog: DefaultDict[str, float] = defaultdict(int)
  638 + progress: DefaultDict[str, float] = defaultdict(int)
586 639  
587   - for u, topic, level, date in student_topics:
  640 + for student, topic, level, date in student_topics:
588 641 if topic in goals:
589 642 date = datetime.strptime(date, "%Y-%m-%d %H:%M:%S.%f")
590   - prog[u] += level**(now - date).days / len(goals)
591   -
592   - ghostuser = len(uid) <= 2 # ghosts are invisible to students
593   - rankings = [(u, name, prog[u], perf.get(u, 0.0))
594   - for u, name in students
595   - if u in prog
596   - and (len(u) > 2 or ghostuser) and u != '0' ]
597   - rankings.sort(key=lambda x: x[2], reverse=True)
598   - return rankings
  643 + progress[student] += level**(now - date).days / len(goals)
  644 +
  645 + return sorted(((u, name, progress[u], perf.get(u, 0.0))
  646 + for u, name in students
  647 + if u in progress and (len(u) > 2 or len(uid) <= 2)),
  648 + key=lambda x: x[2], reverse=True)
599 649  
600 650 # ------------------------------------------------------------------------
... ...
aprendizations/main.py
1 1 #!/usr/bin/env python3
2 2  
  3 +'''
  4 +Setup configurations and then runs the application.
  5 +'''
  6 +
  7 +
3 8 # python standard library
4 9 import argparse
5   -import logging
  10 +import logging.config
6 11 from os import environ, path
7 12 import ssl
8 13 import sys
9 14 from typing import Any, Dict
10 15  
11 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 24 def parse_cmdline_arguments():
  25 + '''
  26 + Parses command line arguments. Uses the argparse package.
  27 + '''
  28 +
20 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 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 45 argparser.add_argument(
32 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 50 argparser.add_argument(
37 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 55 argparser.add_argument(
... ... @@ -44,18 +58,13 @@ def parse_cmdline_arguments():
44 58 )
45 59  
46 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 65 argparser.add_argument(
52 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 70 return argparser.parse_args()
... ... @@ -63,6 +72,12 @@ def parse_cmdline_arguments():
63 72  
64 73 # ----------------------------------------------------------------------------
65 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 81 if debug:
67 82 filename, level = 'logger-debug.yaml', 'DEBUG'
68 83 else:
... ... @@ -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 124 def main():
  125 + '''
  126 + Start application and webserver
  127 + '''
  128 +
112 129 # --- Commandline argument parsing
113 130 arg = parse_cmdline_arguments()
114 131  
115 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 134 sys.exit(0)
118 135  
119 136 # --- Setup logging
... ... @@ -122,8 +139,8 @@ def main():
122 139  
123 140 try:
124 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 144 sys.exit(1)
128 145  
129 146 logging.info('====================== Start Logging ======================')
... ... @@ -139,7 +156,7 @@ def main():
139 156 ssl_ctx.load_cert_chain(path.join(certs_dir, 'cert.pem'),
140 157 path.join(certs_dir, 'privkey.pem'))
141 158 except FileNotFoundError:
142   - logging.critical(f'SSL certificates missing in {certs_dir}')
  159 + logging.critical('SSL certificates missing in %s', certs_dir)
143 160 print('--------------------------------------------------------------',
144 161 'Certificates should be issued by a certificate authority (CA),',
145 162 'such as https://letsencrypt.org. ',
... ... @@ -178,12 +195,14 @@ def main():
178 195 '--------------------------------------------------------------',
179 196 sep='\n')
180 197 sys.exit(1)
181   - except LearnException as e:
  198 + except LearnException as exc:
182 199 logging.critical('Failed to start backend')
183   - sys.exit(1)
  200 + # sys.exit(1)
  201 + raise
184 202 except Exception:
185 203 logging.critical('Unknown error')
186   - sys.exit(1)
  204 + # sys.exit(1)
  205 + raise
187 206 else:
188 207 logging.info('LearnApp started')
189 208  
... ...
aprendizations/models.py
1 1  
2   -# python standard library
3   -from typing import Any
4   -
5 2 # third party libraries
6 3 from sqlalchemy import Column, ForeignKey, Integer, Float, String
7   -from sqlalchemy.ext.declarative import declarative_base
8   -from sqlalchemy.orm import relationship
  4 +from sqlalchemy.orm import declarative_base, relationship
9 5  
10 6  
11 7 # ===========================================================================
12 8 # Declare ORM
13 9 # FIXME Any is a workaround for mypy static type checking (see https://github.com/python/mypy/issues/6372)
14   -Base: Any = declarative_base()
  10 +# from typing import Any
  11 +# Base: Any = declarative_base()
  12 +Base = declarative_base()
15 13  
16 14  
17 15 # ---------------------------------------------------------------------------
... ... @@ -27,11 +25,11 @@ class StudentTopic(Base):
27 25 topic = relationship('Topic', back_populates='students')
28 26  
29 27 def __repr__(self):
30   - return f'''StudentTopic:
31   - student_id: "{self.student_id}"
32   - topic_id: "{self.topic_id}"
33   - level: "{self.level}"
34   - date: "{self.date}"'''
  28 + return ('StudentTopic('
  29 + f'student_id={self.student_id!r}, '
  30 + f'topic_id={self.topic_id!r}, '
  31 + f'level={self.level!r}, '
  32 + f'date={self.date!r})')
35 33  
36 34  
37 35 # ---------------------------------------------------------------------------
... ... @@ -48,10 +46,10 @@ class Student(Base):
48 46 topics = relationship('StudentTopic', back_populates='student')
49 47  
50 48 def __repr__(self):
51   - return f'''Student:
52   - id: "{self.id}"
53   - name: "{self.name}"
54   - password: "{self.password}"'''
  49 + return ('Student('
  50 + f'id={self.id!r}, '
  51 + f'name={self.name!r}, '
  52 + f'password={self.password!r})')
55 53  
56 54  
57 55 # ---------------------------------------------------------------------------
... ... @@ -72,14 +70,14 @@ class Answer(Base):
72 70 topic = relationship('Topic', back_populates='answers')
73 71  
74 72 def __repr__(self):
75   - return f'''Question:
76   - id: "{self.id}"
77   - ref: "{self.ref}"
78   - grade: "{self.grade}"
79   - starttime: "{self.starttime}"
80   - finishtime: "{self.finishtime}"
81   - student_id: "{self.student_id}"
82   - topic_id: "{self.topic_id}"'''
  73 + return ('Question('
  74 + f'id={self.id!r}, '
  75 + f'ref={self.ref!r}, '
  76 + f'grade={self.grade!r}, '
  77 + f'starttime={self.starttime!r}, '
  78 + f'finishtime={self.finishtime!r}, '
  79 + f'student_id={self.student_id!r}, '
  80 + f'topic_id={self.topic_id!r})')
83 81  
84 82  
85 83 # ---------------------------------------------------------------------------
... ... @@ -94,5 +92,4 @@ class Topic(Base):
94 92 answers = relationship('Answer', back_populates='topic')
95 93  
96 94 def __repr__(self):
97   - return f'''Topic:
98   - id: "{self.id}"'''
  95 + return f'Topic(id={self.id!r})'
... ...
aprendizations/questions.py
  1 +'''
  2 +Classes the implement several types of questions.
  3 +'''
  4 +
1 5  
2 6 # python standard library
3 7 import asyncio
4 8 from datetime import datetime
  9 +import logging
  10 +from os import path
5 11 import random
6 12 import re
7   -from os import path
8   -import logging
9 13 from typing import Any, Dict, NewType
10 14 import uuid
11 15  
12 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 19 # setup logger for this module
16 20 logger = logging.getLogger(__name__)
17 21  
18   -
19 22 QDict = NewType('QDict', Dict[str, Any])
20 23  
21 24  
22 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 36 for each student.
34 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 45 # add required keys if missing
40 46 self.set_defaults(QDict({
... ... @@ -46,20 +52,23 @@ class Question(dict):
46 52 }))
47 53  
48 54 def set_answer(self, ans) -> None:
  55 + '''set answer field and register time'''
49 56 self['answer'] = ans
50 57 self['finish_time'] = datetime.now()
51 58  
52 59 def correct(self) -> None:
  60 + '''default correction (synchronous version)'''
53 61 self['comments'] = ''
54 62 self['grade'] = 0.0
55 63  
56 64 async def correct_async(self) -> None:
  65 + '''default correction (async version)'''
57 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 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 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 104 self.set_defaults(QDict({
92 105 'text': '',
93 106 'correct': 0,
94 107 'shuffle': True,
95 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 112 # check correct bounds and convert int to list,
100 113 # e.g. correct: 2 --> correct: [0,0,1,0,0]
101 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 119 raise QuestionException(msg)
106 120  
107 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 124 elif isinstance(self['correct'], list):
111 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 130 raise QuestionException(msg)
  131 +
116 132 # make sure is a list of floats
117 133 try:
118 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 141 # check grade boundaries
125 142 if self['discount'] and not all(0.0 <= x <= 1.0
126 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 147 raise QuestionException(msg)
130 148  
131 149 # at least one correct option
132 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 154 raise QuestionException(msg)
136 155  
137 156 # If shuffle==false, all options are shown as defined
138 157 # otherwise, select 1 correct and choose a few wrong ones
139 158 if self['shuffle']:
140 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 163 self.set_defaults(QDict({'choose': 1+len(wrong)}))
145 164  
146 165 # try to choose 1 correct option
147 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 170 else:
152 171 options = []
153 172 correct = []
... ... @@ -164,20 +183,23 @@ class QuestionRadio(Question):
164 183 self['correct'] = [correct[i] for i in perm]
165 184  
166 185 # ------------------------------------------------------------------------
167   - # can assign negative grades for wrong answers
168 186 def correct(self) -> None:
  187 + '''
  188 + Correct `answer` and set `grade`.
  189 + Can assign negative grades for wrong answers
  190 + '''
169 191 super().correct()
170 192  
171 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 198 # note: there are no numerical errors when summing 1.0s so the
177 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 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 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 233 # set defaults if missing
210 234 self.set_defaults(QDict({
211 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 237 'shuffle': True,
214 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 243 # must be a list of numbers
220 244 if not isinstance(self['correct'], list):
221 245 msg = 'Correct must be a list of numbers or booleans'
  246 + logger.error(msg)
222 247 raise QuestionException(msg)
223 248  
224 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 254 raise QuestionException(msg)
229 255  
230 256 # make sure is a list of floats
231 257 try:
232 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 265 # check grade boundaries
239 266 if self['discount'] and not all(0.0 <= x <= 1.0
240 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 274 # if an option is a list of (right, wrong), pick one
258 275 options = []
259 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 286 # generate random permutation, e.g. [2,1,4,0,3]
271 287 # and apply to `options` and `correct`
272 288 if self['shuffle']:
273   - perm = random.sample(range(n), k=self['choose'])
  289 + perm = random.sample(range(nopts), k=self['choose'])
274 290 self['options'] = [options[i] for i in perm]
275 291 self['correct'] = [correct[i] for i in perm]
276 292 else:
... ... @@ -283,18 +299,18 @@ class QuestionCheckbox(Question):
283 299 super().correct()
284 300  
285 301 if self['answer'] is not None:
286   - x = 0.0
  302 + grade = 0.0
287 303 if self['discount']:
288 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 307 else:
292 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 312 try:
297   - self['grade'] = x / sum_abs
  313 + self['grade'] = grade / sum_abs
298 314 except ZeroDivisionError:
299 315 self['grade'] = 1.0 # limit p->0
300 316  
... ... @@ -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 330 self.set_defaults(QDict({
316 331 'text': '',
317 332 'correct': [], # no correct answers, always wrong
... ... @@ -325,33 +340,36 @@ class QuestionText(Question):
325 340 # make sure all elements of the list are strings
326 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 347 raise QuestionException(msg)
333 348  
334 349 # check if answers are invariant with respect to the transforms
335 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 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 365 ans = re.sub(r'\s+', ' ', ans.strip())
349   - elif f == 'lower': # convert to lowercase
  366 + elif transform == 'lower': # convert to lowercase
350 367 ans = ans.lower()
351   - elif f == 'upper': # convert to uppercase
  368 + elif transform == 'upper': # convert to uppercase
352 369 ans = ans.upper()
353 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 373 return ans
356 374  
357 375 # ------------------------------------------------------------------------
... ... @@ -359,7 +377,7 @@ class QuestionText(Question):
359 377 super().correct()
360 378  
361 379 if self['answer'] is not None:
362   - answer = self.transform(self['answer']) # apply transformations
  380 + answer = self.transform(self['answer'])
363 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 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 400 self.set_defaults(QDict({
383 401 'text': '',
... ... @@ -388,27 +406,19 @@ class QuestionTextRegex(Question):
388 406 if not isinstance(self['correct'], list):
389 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 410 def correct(self) -> None:
400 411 super().correct()
401 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 414 try:
405   - if r.match(self['answer']):
  415 + if re.fullmatch(regex, self['answer']):
406 416 self['grade'] = 1.0
407 417 return
408 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 424 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 437 self.set_defaults(QDict({
428 438 'text': '',
... ... @@ -438,19 +448,22 @@ class QuestionNumericInterval(Question):
438 448 if len(self['correct']) != 2:
439 449 msg = (f'Numeric interval must be a list with two numbers, in '
440 450 f'{self["ref"]}')
  451 + logger.error(msg)
441 452 raise QuestionException(msg)
442 453  
443 454 try:
444 455 self['correct'] = [float(n) for n in self['correct']]
445   - except Exception:
  456 + except Exception as exc:
446 457 msg = (f'Numeric interval must be a list with two numbers, in '
447 458 f'{self["ref"]}')
448   - raise QuestionException(msg)
  459 + logger.error(msg)
  460 + raise QuestionException(msg) from exc
449 461  
450 462 # invalid
451 463 else:
452 464 msg = (f'Numeric interval must be a list with two numbers, in '
453 465 f'{self["ref"]}')
  466 + logger.error(msg)
454 467 raise QuestionException(msg)
455 468  
456 469 # ------------------------------------------------------------------------
... ... @@ -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 498 self.set_defaults(QDict({
486 499 'text': '',
... ... @@ -504,21 +517,22 @@ class QuestionTextArea(Question):
504 517 )
505 518  
506 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 522 self['grade'] = 0.0
509 523 elif isinstance(out, dict):
510 524 self['comments'] = out.get('comments', '')
511 525 try:
512 526 self['grade'] = float(out['grade'])
513 527 except ValueError:
514   - logger.error(f'Output error in "{self["correct"]}".')
  528 + logger.error('Output error in "%s".', self["correct"])
515 529 except KeyError:
516   - logger.error(f'No grade in "{self["correct"]}".')
  530 + logger.error('No grade in "%s".', self["correct"])
517 531 else:
518 532 try:
519 533 self['grade'] = float(out)
520 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 538 async def correct_async(self) -> None:
... ... @@ -533,28 +547,34 @@ class QuestionTextArea(Question):
533 547 )
534 548  
535 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 552 self['grade'] = 0.0
538 553 elif isinstance(out, dict):
539 554 self['comments'] = out.get('comments', '')
540 555 try:
541 556 self['grade'] = float(out['grade'])
542 557 except ValueError:
543   - logger.error(f'Output error in "{self["correct"]}".')
  558 + logger.error('Output error in "%s".', self["correct"])
544 559 except KeyError:
545   - logger.error(f'No grade in "{self["correct"]}".')
  560 + logger.error('No grade in "%s".', self["correct"])
546 561 else:
547 562 try:
548 563 self['grade'] = float(out)
549 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 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 578 self.set_defaults(QDict({
559 579 'text': '',
560 580 }))
... ... @@ -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 594 'radio': QuestionRadio,
605 595 'checkbox': QuestionCheckbox,
606 596 'text': QuestionText,
... ... @@ -614,48 +604,92 @@ class QFactory(object):
614 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 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 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 670 # Shallow copy so that script generated questions will not replace
627 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 675 # If question is of generator type, an external program will be run
632 676 # which will print a valid question in yaml format to stdout. This
633 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 693 def generate(self) -> Question:
  694 + '''generate question (synchronous version)'''
661 695 return asyncio.get_event_loop().run_until_complete(self.gen_async())
... ...
aprendizations/serve.py
  1 +'''
  2 +Webserver
  3 +'''
  4 +
1 5  
2 6 # python standard library
3 7 import asyncio
4 8 import base64
5 9 import functools
6   -import logging.config
  10 +import logging
7 11 import mimetypes
8   -from os import path
  12 +from os.path import join, dirname, expanduser
9 13 import signal
10 14 import sys
11 15 from typing import List, Optional, Union
12 16 import uuid
13 17  
14 18 # third party libraries
  19 +import tornado.httpserver
  20 +import tornado.ioloop
15 21 import tornado.web
16 22 from tornado.escape import to_unicode
17 23  
18 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 30 # setup logger for this module
... ... @@ -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 35 def admin_only(func):
  36 + '''
  37 + Decorator used to restrict access to the administrator
  38 + '''
31 39 @functools.wraps(func)
32 40 def wrapper(self, *args, **kwargs):
33 41 if self.current_user != '0':
34 42 raise tornado.web.HTTPError(403) # forbidden
35   - else:
36   - func(self, *args, **kwargs)
  43 + func(self, *args, **kwargs)
37 44 return wrapper
38 45  
39 46  
40 47 # ============================================================================
41   -# WebApplication - Tornado Web Server
42   -# ============================================================================
43 48 class WebApplication(tornado.web.Application):
44   -
  49 + '''
  50 + WebApplication - Tornado Web Server
  51 + '''
45 52 def __init__(self, learnapp, debug=False):
46 53 handlers = [
47   - (r'/login', LoginHandler),
48   - (r'/logout', LogoutHandler),
  54 + (r'/login', LoginHandler),
  55 + (r'/logout', LogoutHandler),
49 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 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 68 'static_url_prefix': '/static/',
62 69 'xsrf_cookies': True,
63 70 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
... ... @@ -71,30 +78,40 @@ class WebApplication(tornado.web.Application):
71 78 # ============================================================================
72 79 # Handlers
73 80 # ============================================================================
74   -
75   -# ----------------------------------------------------------------------------
76   -# Base handler common to all handlers.
77   -# ----------------------------------------------------------------------------
  81 +# pylint: disable=abstract-method
78 82 class BaseHandler(tornado.web.RequestHandler):
  83 + '''
  84 + Base handler common to all handlers.
  85 + '''
79 86 @property
80 87 def learn(self):
  88 + '''easier access to learnapp'''
81 89 return self.application.learn
82 90  
83 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 106 class RankingsHandler(BaseHandler):
  107 + '''
  108 + Handles rankings page
  109 + '''
96 110 @tornado.web.authenticated
97 111 def get(self):
  112 + '''
  113 + Renders list of students that have answers in this course.
  114 + '''
98 115 uid = self.current_user
99 116 current_course = self.learn.get_current_course_id(uid)
100 117 course_id = self.get_query_argument('course', default=current_course)
... ... @@ -110,23 +127,33 @@ class RankingsHandler(BaseHandler):
110 127  
111 128  
112 129 # ----------------------------------------------------------------------------
113   -# /auth/login
  130 +#
114 131 # ----------------------------------------------------------------------------
115 132 class LoginHandler(BaseHandler):
  133 + '''
  134 + Handles /login
  135 + '''
116 136 def get(self):
  137 + '''
  138 + Renders login page
  139 + '''
117 140 self.render('login.html',
118 141 appname=APP_NAME,
119 142 error='')
120 143  
121 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 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 157 self.set_secure_cookie('counter', counter)
131 158 self.redirect('/')
132 159 else:
... ... @@ -136,11 +163,15 @@ class LoginHandler(BaseHandler):
136 163  
137 164  
138 165 # ----------------------------------------------------------------------------
139   -# /auth/logout
140   -# ----------------------------------------------------------------------------
141 166 class LogoutHandler(BaseHandler):
  167 + '''
  168 + Handles /logout
  169 + '''
142 170 @tornado.web.authenticated
143 171 def get(self):
  172 + '''
  173 + clears cookies and removes user session
  174 + '''
144 175 self.clear_cookie('user')
145 176 self.clear_cookie('counter')
146 177 self.redirect('/')
... ... @@ -151,12 +182,18 @@ class LogoutHandler(BaseHandler):
151 182  
152 183 # ----------------------------------------------------------------------------
153 184 class ChangePasswordHandler(BaseHandler):
  185 + '''
  186 + Handles password change
  187 + '''
154 188 @tornado.web.authenticated
155 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 197 if changed_ok:
161 198 notification = self.render_string(
162 199 'notification.html',
... ... @@ -174,45 +211,56 @@ class ChangePasswordHandler(BaseHandler):
174 211  
175 212  
176 213 # ----------------------------------------------------------------------------
177   -# /
178   -# redirects to appropriate place
179   -# ----------------------------------------------------------------------------
180 214 class RootHandler(BaseHandler):
  215 + '''
  216 + Handles root /
  217 + '''
181 218 @tornado.web.authenticated
182 219 def get(self):
  220 + '''Simply redirects to the main entrypoint'''
183 221 self.redirect('/courses')
184 222  
185 223  
186 224 # ----------------------------------------------------------------------------
187   -# /courses
188   -# Shows a list of available courses
189   -# ----------------------------------------------------------------------------
190 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 232 @tornado.web.authenticated
192 233 def get(self):
  234 + '''Renders list of available courses'''
193 235 uid = self.current_user
194 236 self.render('courses.html',
195 237 appname=APP_NAME,
196 238 uid=uid,
197 239 name=self.learn.get_student_name(uid),
198 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 246 class CourseHandler(BaseHandler):
  247 + '''
  248 + Handles a particular course to show the topics table
  249 + '''
  250 +
207 251 @tornado.web.authenticated
208 252 def get(self, course_id):
  253 + '''
  254 + Handles get /course/...
  255 + Starts a given course and show list of topics
  256 + '''
209 257 uid = self.current_user
210 258 if course_id == '':
211 259 course_id = self.learn.get_current_course_id(uid)
212 260  
213 261 try:
214 262 self.learn.start_course(uid, course_id)
215   - except KeyError:
  263 + except LearnException:
216 264 self.redirect('/courses')
217 265  
218 266 self.render('maintopics-table.html',
... ... @@ -225,17 +273,24 @@ class CourseHandler(BaseHandler):
225 273 )
226 274  
227 275  
228   -# ----------------------------------------------------------------------------
229   -# /topic/...
230   -# Start a given topic
231   -# ----------------------------------------------------------------------------
  276 +# ============================================================================
232 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 284 @tornado.web.authenticated
234 285 async def get(self, topic):
  286 + '''
  287 + Handles get /topic/...
  288 + Starts a given topic
  289 + '''
235 290 uid = self.current_user
236 291  
237 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 294 except KeyError:
240 295 self.redirect('/topics')
241 296  
... ... @@ -243,42 +298,43 @@ class TopicHandler(BaseHandler):
243 298 appname=APP_NAME,
244 299 uid=uid,
245 300 name=self.learn.get_student_name(uid),
246   - # course_title=self.learn.get_student_course_title(uid),
247 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 306 class FileHandler(BaseHandler):
  307 + '''
  308 + Serves files from the /public subdir of the topics.
  309 + '''
255 310 @tornado.web.authenticated
256 311 async def get(self, filename):
  312 + '''
  313 + Serves files from /public subdirectories of a particular topic
  314 + '''
257 315 uid = self.current_user
258 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 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 324 raise
272   - else:
  325 +
  326 + content_type = mimetypes.guess_type(filename)[0]
  327 + if content_type is not None:
273 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 334 class QuestionHandler(BaseHandler):
  335 + '''
  336 + Responds to AJAX to get a JSON question
  337 + '''
282 338 templates = {
283 339 'checkbox': 'question-checkbox.html',
284 340 'radio': 'question-radio.html',
... ... @@ -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 353 @tornado.web.authenticated
302 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 359 logger.debug('[QuestionHandler]')
304 360 user = self.current_user
305   - q = await self.learn.get_question(user)
  361 + question = await self.learn.get_question(user)
306 362  
307 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 367 response = {
312 368 'method': 'new_question',
313 369 'params': {
314   - 'type': q['type'],
  370 + 'type': question['type'],
315 371 'question': to_unicode(qhtml),
316 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 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 390 @tornado.web.authenticated
339 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 396 user = self.current_user
341 397 answer = self.get_body_arguments('answer') # list
342 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 401 # --- check if browser opened different questions simultaneously
346 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 404 self.write({
349 405 'method': 'invalid',
350 406 'params': {
... ... @@ -370,51 +426,55 @@ class QuestionHandler(BaseHandler):
370 426 ans = answer
371 427  
372 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 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 443 response['params'] = {
386   - 'type': q['type'],
  444 + 'type': question['type'],
387 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 455 response['params'] = {
397   - 'type': q['type'],
  456 + 'type': question['type'],
398 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 469 response['params'] = {
410   - 'type': q['type'],
  470 + 'type': question['type'],
411 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 476 else:
417   - logger.error(f'Unknown question status: {q["status"]}')
  477 + logger.error('Unknown question status: %s', question["status"])
418 478  
419 479 self.write(response)
420 480  
... ... @@ -422,29 +482,29 @@ class QuestionHandler(BaseHandler):
422 482 # ----------------------------------------------------------------------------
423 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 491 tornado.ioloop.IOLoop.current().stop()
429   - logger.critical('Webserver stopped.')
  492 + logging.critical('Webserver stopped.')
430 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 502 # --- create web application
442 503 try:
443 504 webapp = WebApplication(app, debug=debug)
444 505 except Exception:
445 506 logger.critical('Failed to start web application.')
446   - raise
447   - # sys.exit(1)
  507 + sys.exit(1)
448 508 else:
449 509 logger.info('Web application started (tornado.web.Application)')
450 510  
... ... @@ -460,14 +520,12 @@ def run_webserver(app,
460 520 try:
461 521 httpserver.listen(port)
462 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 524 sys.exit(1)
465   - else:
466   - logger.info(f'HTTP server listening on port {port}')
467 525  
468 526 # --- run webserver
  527 + logger.info('Webserver listening on %d... (Ctrl-C to stop)', port)
469 528 signal.signal(signal.SIGINT, signal_handler)
470   - logger.info('Webserver running... (Ctrl-C to stop)')
471 529  
472 530 try:
473 531 tornado.ioloop.IOLoop.current().start() # running...
... ...
aprendizations/static/css/animate.min.css
... ... @@ -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 0 \ No newline at end of file
aprendizations/static/css/signin.css 0 → 100644
... ... @@ -0,0 +1,39 @@
  1 +html,
  2 +body {
  3 + height: 100%;
  4 +}
  5 +
  6 +body {
  7 + display: flex;
  8 + align-items: center;
  9 + padding-top: 40px;
  10 + padding-bottom: 40px;
  11 + background-color: #f5f5f5;
  12 +}
  13 +
  14 +.form-signin {
  15 + width: 100%;
  16 + max-width: 330px;
  17 + padding: 15px;
  18 + margin: auto;
  19 +}
  20 +
  21 +.form-signin .checkbox {
  22 + font-weight: 400;
  23 +}
  24 +
  25 +.form-signin .form-floating:focus-within {
  26 + z-index: 2;
  27 +}
  28 +
  29 +.form-signin input[type="email"] {
  30 + margin-bottom: -1px;
  31 + border-bottom-right-radius: 0;
  32 + border-bottom-left-radius: 0;
  33 +}
  34 +
  35 +.form-signin input[type="password"] {
  36 + margin-bottom: 10px;
  37 + border-top-left-radius: 0;
  38 + border-top-right-radius: 0;
  39 +}
... ...
aprendizations/static/css/topic.css
1   -.progress {
2   - /*position: fixed;*/
3   - top: 0;
4   - height: 70px;
5   - border-radius: 0px;
6   -}
7 1 body {
8   - margin: 0;
9   - padding-top: 0px;
10 2 margin-bottom: 120px; /* Margin bottom by footer height */
11 3 }
12 4  
... ... @@ -19,10 +11,6 @@ body {
19 11 /*background-color: #f5f5f5;*/
20 12 }
21 13  
22   -html {
23   - position: relative;
24   - min-height: 100%;
25   -}
26 14 .CodeMirror {
27 15 border: 1px solid #eee;
28 16 height: auto;
... ...
aprendizations/static/js/topic.js
1 1 $.fn.extend({
2 2 animateCSS: function (animation, run_on_end) {
3 3 var animationEnd = 'webkitAnimationEnd mozAnimationEnd MSAnimationEnd oanimationend animationend';
4   - this.addClass('animated ' + animation).one(animationEnd, function() {
5   - $(this).removeClass('animated ' + animation);
  4 + this.addClass('animate__animated ' + animation).one(animationEnd, function() {
  5 + $(this).removeClass('animate__animated ' + animation);
6 6 if (run_on_end !== undefined) {
7 7 run_on_end();
8 8 }
... ... @@ -46,7 +46,7 @@ function updateQuestion(response) {
46 46 break;
47 47 case "finished_topic":
48 48 $('#submit, #comments, #solution').remove();
49   - $("#content").html(params["question"]).animateCSS('tada');
  49 + $("#content").html(params["question"]).animateCSS('animate__tada');
50 50 $('#topic_progress').css('width', '100%').attr('aria-valuenow', 100);
51 51 setTimeout(function(){window.location.replace('/course/');}, 2000);
52 52 break;
... ... @@ -57,10 +57,10 @@ function new_question(type, question, tries, progress) {
57 57 window.scrollTo(0, 0);
58 58  
59 59 $("#submit").hide();
60   - $("#question_div").animateCSS('fadeOut', function() {
  60 + $("#question_div").animateCSS('animate__fadeOut', function() {
61 61 $("#question_div").html(question);
62 62 MathJax.typeset();
63   - $("#question_div").animateCSS('fadeIn', function() {
  63 + $("#question_div").animateCSS('animate__fadeIn', function() {
64 64 showTriesLeft(tries);
65 65 $("#submit").removeClass("disabled").show();
66 66  
... ... @@ -120,7 +120,7 @@ function getFeedback(response) {
120 120 $('#comments').html(params['comments']).show();
121 121 $('#solution_right').html(params['solution']);
122 122 MathJax.typeset();
123   - $('#right').show().animateCSS('zoomIn', function(){
  123 + $('#right').show().animateCSS('animate__zoomIn', function(){
124 124 $("#submit").html("Continuar").removeClass("disabled").off().click(getQuestion);
125 125 });
126 126 break;
... ... @@ -129,7 +129,10 @@ function getFeedback(response) {
129 129 $('#comments').html(params['comments']).show();
130 130 MathJax.typeset();
131 131 $('#topic_progress').css('width', (100*params["progress"])+'%').attr('aria-valuenow', 100*params["progress"]);
132   - $('#question_div').animateCSS('shake', function() {
  132 +
  133 +
  134 +
  135 + $('#question_div').animateCSS('animate__shakeX', function() {
133 136 showTriesLeft(params["tries"]);
134 137 $("fieldset").prop("disabled", false);
135 138 $("#submit").html("Responder").removeClass("disabled");
... ... @@ -142,9 +145,9 @@ function getFeedback(response) {
142 145 $('#comments').html(params['comments']).show();
143 146 $('#solution_wrong').html(params['solution']);
144 147 MathJax.typeset();
145   - $('#question_div').animateCSS('shake', function() {
  148 + $('#question_div').animateCSS('animate__shakeX', function() {
146 149 showTriesLeft(params["tries"]);
147   - $('#wrong').show().animateCSS('zoomIn', function() {
  150 + $('#wrong').show().animateCSS('animate__zoomIn', function() {
148 151 $("#submit").html("Continuar").removeClass("disabled").off().click(getQuestion);
149 152 });
150 153 });
... ...
aprendizations/static/mdbootstrap
... ... @@ -1 +0,0 @@
1   -../../node_modules/mdbootstrap/
2 0 \ No newline at end of file
aprendizations/student.py
1 1  
  2 +'''
  3 +Implementation of the StudentState class.
  4 +Each object of this class will contain the state of a student while logged in.
  5 +Manages things like current course, topic, question, etc, and defines the
  6 +logic of the application in what it applies to a single student.
  7 +'''
  8 +
2 9 # python standard library
3 10 from datetime import datetime
4 11 import logging
5 12 import random
6   -from typing import List, Optional, Tuple
  13 +from typing import List, Optional
7 14  
8 15 # third party libraries
9 16 import networkx as nx
10 17  
11 18 # this project
12   -from .questions import Question
  19 +from aprendizations.questions import Question
13 20  
14 21  
15 22 # setup logger for this module
16 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 45 # methods that update state
39   - # =======================================================================
  46 + # ========================================================================
40 47 def __init__(self, uid, state, courses, deps, factory) -> None:
41 48 # shared application data between all students
42 49 self.deps = deps # dependency graph
... ... @@ -54,6 +61,10 @@ class StudentState(object):
54 61  
55 62 # ------------------------------------------------------------------------
56 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 68 if course is None:
58 69 logger.debug('no active course')
59 70 self.current_course: Optional[str] = None
... ... @@ -63,125 +74,136 @@ class StudentState(object):
63 74 try:
64 75 topics = self.courses[course]['goals']
65 76 except KeyError:
66   - logger.debug(f'course "{course}" does not exist')
  77 + logger.debug('course "%s" does not exist', course)
67 78 raise
68   - logger.debug(f'starting course "{course}"')
  79 + logger.debug('starting course "%s"', course)
69 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 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 95 logger.info('Restarting current topic is not allowed.')
83 96 return
84 97  
85 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 101 return
89 102  
90 103 self.previous_topic: Optional[str] = None
91 104  
92 105 # choose k questions
93   - self.current_topic = topic
  106 + self.current_topic = topic_ref
94 107 self.correct_answers = 0
95 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 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 117 self.questions: List[Question] = [await self.factory[ref].gen_async()
105 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 122 # get first question
110 123 self.next_question()
111 124  
112 125 # ------------------------------------------------------------------------
113   - # corrects current question
114   - # updates keys: answer, grade, finish_time, status, tries
115   - # ------------------------------------------------------------------------
116 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 134 logger.error('check_answer called but current_question is None!')
120 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 141 self.correct_answers += 1
127   - q['status'] = 'right'
  142 + question['status'] = 'right'
128 143  
129 144 else:
130 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 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 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 164 logger.error('get_question called but current_question is None!')
147 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 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 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 175 self.questions.append(new_question)
158 176 self.next_question()
159 177  
160 178 return self.current_question
161 179  
162 180 # ------------------------------------------------------------------------
163   - # moves to next question
164   - # ------------------------------------------------------------------------
165 181 def next_question(self) -> None:
  182 + '''
  183 + Moves to next question
  184 + '''
  185 +
166 186 try:
167   - q = self.questions.pop(0)
  187 + question = self.questions.pop(0)
168 188 except IndexError:
169 189 self.finish_topic()
170 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 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 208 self.state[self.current_topic] = {
187 209 'date': datetime.now(),
... ... @@ -194,22 +216,25 @@ class StudentState(object):
194 216 self.unlock_topics()
195 217  
196 218 # ------------------------------------------------------------------------
197   - # Update proficiency level of the topics using a forgetting factor
198   - # ------------------------------------------------------------------------
199 219 def update_topic_levels(self) -> None:
  220 + '''
  221 + Update proficiency level of the topics using a forgetting factor
  222 + '''
  223 +
200 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 227 try:
204 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 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 234 def unlock_topics(self) -> None:
  235 + '''
  236 + Unlock topics whose dependencies are satisfied (> min_level)
  237 + '''
213 238 for topic in self.deps.nodes():
214 239 if topic not in self.state: # if locked
215 240 pred = self.deps.predecessors(topic)
... ... @@ -221,7 +246,7 @@ class StudentState(object):
221 246 'level': 0.0, # unlock
222 247 'date': datetime.now()
223 248 }
224   - logger.debug(f'unlocked "{topic}"')
  249 + logger.debug('unlocked "%s"', topic)
225 250 # else: # lock this topic if deps do not satisfy min_level
226 251 # del self.state[topic]
227 252  
... ... @@ -230,64 +255,78 @@ class StudentState(object):
230 255 # ========================================================================
231 256  
232 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 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 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 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 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 290 return unlocked + locked
260 291  
261 292 # ------------------------------------------------------------------------
262 293 def get_current_question(self) -> Optional[Question]:
  294 + '''gets current question'''
263 295 return self.current_question
264 296  
265 297 # ------------------------------------------------------------------------
266 298 def get_current_topic(self) -> Optional[str]:
  299 + '''gets current topic'''
267 300 return self.current_topic
268 301  
269 302 # ------------------------------------------------------------------------
270 303 def get_previous_topic(self) -> Optional[str]:
  304 + '''gets previous topic'''
271 305 return self.previous_topic
272 306  
273 307 # ------------------------------------------------------------------------
274 308 def get_current_course_title(self) -> str:
  309 + '''gets current course title'''
275 310 return str(self.courses[self.current_course]['title'])
276 311  
277 312 # ------------------------------------------------------------------------
278 313 def get_current_course_id(self) -> Optional[str]:
  314 + '''gets current course id'''
279 315 return self.current_course
280 316  
281 317 # ------------------------------------------------------------------------
282 318 def is_locked(self, topic: str) -> bool:
  319 + '''checks if a given topic is locked'''
283 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 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 330 return [{
292 331 'ref': ref,
293 332 'type': self.deps.nodes[ref]['type'],
... ... @@ -297,19 +336,16 @@ class StudentState(object):
297 336  
298 337 # ------------------------------------------------------------------------
299 338 def get_topic_progress(self) -> float:
  339 + '''computes progress of the current topic'''
300 340 return self.correct_answers / (1 + self.correct_answers +
301 341 len(self.questions))
302 342  
303 343 # ------------------------------------------------------------------------
304 344 def get_topic_level(self, topic: str) -> float:
  345 + '''gets level of a given topic'''
305 346 return float(self.state[topic]['level'])
306 347  
307 348 # ------------------------------------------------------------------------
308 349 def get_topic_date(self, topic: str):
  350 + '''gets date of a given topic'''
309 351 return self.state[topic]['date']
310   -
311   - # ------------------------------------------------------------------------
312   - # Recommends a topic to practice/learn from the state.
313   - # ------------------------------------------------------------------------
314   - # def get_recommended_topic(self): # FIXME untested
315   - # return min(self.state.items(), key=lambda x: x[1]['level'])[0]
... ...
aprendizations/templates/courses.html
1 1 {% autoescape %}
2   -<!doctype html>
3   -<html lang="pt-PT">
4 2  
5   -<head>
6   - <title>{{appname}}</title>
7   - <link rel="icon" href="/static/favicon.ico">
8   - <meta charset="utf-8">
9   - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  3 +<!DOCTYPE html>
  4 +<html lang="pt-PT">
  5 + <head>
  6 + <meta charset="utf-8" />
  7 + <meta name="viewport" content="width=device-width, initial-scale=1">
10 8 <meta name="author" content="Miguel Barão">
  9 + <link rel="icon" href="favicon.ico">
11 10 <!-- Styles -->
12   - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">
13   - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">
14   - <link rel="stylesheet" href="/static/css/maintopics.css">
15   - <link rel="stylesheet" href="/static/css/sticky-footer-navbar.css">
  11 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  12 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
  13 + <link rel="stylesheet" href="{{static_url('css/sticky-footer-navbar.css')}}">
16 14 <!-- Scripts -->
17   - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>
18   - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>
19   - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>
20   - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>
21   - <script defer src="/static/fontawesome-free/js/all.min.js"></script>
22   - <script defer src="/static/js/maintopics.js"></script>
23   -</head>
  15 + <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  16 + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
  17 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  18 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
  19 +
  20 + <title>{{appname}}</title>
  21 + </head>
24 22  
25   -<body>
26   - <!-- ===== navbar ==================================================== -->
27   - <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">
28   - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">
29   - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
30   - <span class="navbar-toggler-icon"></span>
  23 + <body>
  24 + <!-- ===== navbar ======================================================== -->
  25 + <nav class="navbar navbar-expand-sm navbar-dark bg-primary fixed-top shadow">
  26 + <div class="container-fluid">
  27 + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora">
  28 + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  29 + <span class="navbar-toggler-icon"></span>
31 30 </button>
32   - <div class="collapse navbar-collapse" id="navbarText">
33   - <div class="navbar-nav mr-auto">
34   - <a class="nav-item nav-link active" href="#">Cursos <span class="sr-only">(actual)</span></a>
35   - <a class="nav-item nav-link disabled" href="#">Tópicos</a>
36   - <a class="nav-item nav-link disabled" href="#" tabindex="-1" aria-disabled="true">Classificação</a>
37   - </div>
38   - <ul class="navbar-nav">
39   - <li class="nav-item dropdown">
40   - <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
41   - <i class="fas fa-user-graduate" aria-hidden="true"></i>
42   - <span id="name">{{ escape(name) }}</span>
43   - <span class="caret"></span>
44   - </a>
45   - <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
46   - <a class="dropdown-item" data-toggle="modal" data-target="#password_modal">Mudar Password</a>
47   - <div class="dropdown-divider"></div>
48   - <a class="dropdown-item" href="/logout">Sair</a>
49   - </div>
50   - </li>
51   - </ul>
  31 +
  32 + <div class="collapse navbar-collapse" id="navbarNavText">
  33 + <ul class="navbar-nav">
  34 + <li class="nav-item"><a class="nav-link active" aria-current="page" href="/courses">Cursos</a></li>
  35 + <li class="nav-item"><a class="nav-link disabled" href="#">Tópicos</a></li>
  36 + <li class="nav-item"><a class="nav-link disabled" href="#">Classificação</a></li>
  37 + </ul>
  38 + <ul class="navbar-nav ms-auto">
  39 + <li class="nav-item dropdown">
  40 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  41 + <i class="fas fa-user-graduate" aria-hidden="true"></i>
  42 + &nbsp;
  43 + <span id="name">{{ escape(name) }}</span>
  44 + <span class="caret"></span>
  45 + </a>
  46 + <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
  47 + <li><a class="dropdown-item" data-bs-toggle="modal" data-bs-target="#password_modal">Mudar Password</a></li>
  48 + <li><hr class="dropdown-divider"></li>
  49 + <li><a class="dropdown-item" href="/logout">Sair</a></li>
  50 + </ul>
  51 + </li>
  52 + </ul>
52 53 </div>
  54 + </div>
53 55 </nav>
54   - <!-- ===== page ====================================================== -->
55   - <div class="container">
56   - <div id="notifications"></div>
57   - <div class="row justify-content-left">
58   - {% for k,v in courses.items() %}
59   - <div class="card-deck col-md-4">
60   - <a href="/course/{{k}}" class="card mb-3">
61   - <div class="card-body">
62   - <h6 class="card-title">{{ v['title'] }}</h6>
63   - <p class="card-text">{{ v.get('description', '') }}</p>
64   - </div>
65   - </a>
  56 + <!-- === Change Password Modal =========================================== -->
  57 + <div id="password_modal" class="modal fade" tabindex="-1" aria-labelledby="password_modal" aria-hidden="true">
  58 + <div class="modal-dialog">
  59 + <div class="modal-content">
  60 + <!-- header -->
  61 + <div class="modal-header">
  62 + <h5 class="modal-title">Alterar Password</h5>
  63 + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  64 + </div>
  65 + <!-- body -->
  66 + <div class="modal-body">
  67 + <div class="control-group">
  68 + <label for="new_password" class="control-label">Introduza a nova password:</label>
  69 + <div class="controls">
  70 + <input type="password" id="new_password" name="new_password" autocomplete="new-password">
  71 + </div>
66 72 </div>
67   - {% end %} <!-- for -->
  73 + </div>
  74 + <!-- footer -->
  75 + <div class="modal-footer">
  76 + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
  77 + <button id="change_password" type="button" class="btn btn-primary" data-bs-dismiss="modal">Alterar</button>
  78 + </div>
  79 + <!-- end -->
68 80 </div>
  81 + </div>
69 82 </div>
70 83  
71   - <footer class="footer">
72   - <div class="container">
73   - <small class="text-muted">
74   - <a href="mailto:mjsb@uevora.pt?subject=Encontrei um erro&body=Descreva detalhadamente a situação na qual encontrou o erro. Indique qual o curso, tópico e pergunta. No caso de problemas técnicos indique também qual o seu sistema operativo e browser.">Reportar erros</a>
75   - &nbsp;/&nbsp;
76   - <a href="mailto:mjsb@uevora.pt?subject=Sugestões">Enviar sugestões</a>
77   - </small>
78   - </div>
79   - </footer>
80   -
81   - <!-- === Change Password Modal =========================================== -->
82   - <div id="password_modal" class="modal fade" tabindex="-1" role="dialog">
83   - <div class="modal-dialog" role="document">
84   - <div class="modal-content">
85   - <!-- header -->
86   - <div class="modal-header">
87   - <h5 class="modal-title">Alterar Password</h5>
88   - </div>
89   - <!-- body -->
90   - <div class="modal-body">
91   - <div class="control-group">
92   - <label for="new_password" class="control-label">Introduza a nova password:</label>
93   - <div class="controls">
94   - <input type="password" id="new_password" name="new_password" autocomplete="new-password">
95   - </div>
96   - </div>
97   - </div>
98   - <!-- footer -->
99   - <div class="modal-footer">
100   - <button type="button" class="btn btn-default" data-dismiss="modal">Cancelar</button>
101   - <button id="change_password" type="button" class="btn btn-danger" data-dismiss="modal">Alterar</button>
102   - </div>
103   - </div><!-- /.modal-content -->
104   - </div><!-- /.modal-dialog -->
105   - </div><!-- /.modal -->
106   -</body>
  84 + <!-- ===== page ========================================================== -->
  85 + <div class="container">
  86 + <div id="notifications" style="position:fixed; z-index: 999;"></div>
107 87  
  88 + <div class="row row-cols-1 row-cols-md-3 g-4">
  89 + {% for k,v in courses.items() %}
  90 + <div class="col">
  91 + <div class="card bg-light shadow">
  92 + <div class="card-body">
  93 + <h5>{{ v['title'] }}</h5>
  94 + <p class="card-text">{{ v.get('description', '') }}</p>
  95 + <a href="/course/{{k}}" class="stretched-link">Iniciar</a>
  96 + </div>
  97 + </div>
  98 + </div>
  99 + {% end %}
  100 + </div>
  101 + </div>
  102 + </body>
108 103 </html>
... ...
aprendizations/templates/login.html
1   -<!doctype html>
2   -<html lang="pt">
3   -<head>
4   - <title>{{appname}}</title>
5   - <link rel="icon" href="/static/favicon.ico">
6   -
7   - <meta charset="utf-8">
8   - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
9   - <meta name="author" content="Miguel Barão">
10   -
11   - <!-- Styles -->
12   - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">
13   - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">
14   -
15   - <!-- Scripts -->
16   - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>
17   - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>
18   - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>
19   - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>
20   - <script defer src="/static/fontawesome-free/js/all.min.js"></script>
21   -
22   -</head>
23   -<!-- =================================================================== -->
24   -<body>
25   - <div class="container-fluid">
26   - <div class="card bg-light border-dark mt-3">
27   - <div class="card-body">
28   - <div class="row">
29   -
30   - <div class="col-sm-9">
31   - <img src="/static/logo_horizontal_login.png" class="img-responsive mb-3" width="50%" alt="Universidade de Évora">
32   - </div>
33   -
34   - <div class="col-sm-3">
35   -
36   - <form method="post" action="/login" class="form-signin">
37   - {% module xsrf_form_html() %}
38   - <div class="form-group">
39   - <input type="text" name="uid" class="form-control mb-3" placeholder="Número de aluno" required autofocus>
40   - <input type="password" name="pw" class="form-control mb-3" placeholder="Senha" required>
41   - <p class="text-danger"> {{ error }} </p>
42   - <button class="btn btn-primary" type="submit">
43   - Entrar
44   - </button>
45   - </div>
46   - </form>
47   - </div>
48   -
49   - </div> <!-- row -->
50   - </div> <!-- card-body -->
51   - </div> <!-- card -->
52   - </div> <!-- container -->
53   -</body>
  1 +<!DOCTYPE html>
  2 +<html lang="en">
  3 + <head>
  4 + <meta charset="utf-8" />
  5 + <meta name="viewport" content="width=device-width, initial-scale=1" />
  6 + <meta name="author" content="Miguel Barão">
  7 +
  8 + <!-- <link rel="canonical" href="https://getbootstrap.com/docs/5.1/examples/sign-in/"> -->
  9 +
  10 + <!-- Bootstrap core CSS -->
  11 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  12 + <!-- <link href="../assets/dist/css/bootstrap.min.css" rel="stylesheet"> -->
  13 +
  14 + <style>
  15 + .bd-placeholder-img {
  16 + font-size: 1.125rem;
  17 + text-anchor: middle;
  18 + -webkit-user-select: none;
  19 + -moz-user-select: none;
  20 + user-select: none;
  21 + }
  22 +
  23 + @media (min-width: 768px) {
  24 + .bd-placeholder-img-lg {
  25 + font-size: 3.5rem;
  26 + }
  27 + }
  28 + </style>
  29 +
  30 + <link href="{{static_url('css/signin.css')}}" rel="stylesheet">
  31 +
  32 + <title>{{appname}}</title>
  33 + </head>
  34 + <body class="text-center">
  35 +
  36 + <main class="form-signin">
  37 + <form method="post" action="/login" class="form-signin">
  38 + {% module xsrf_form_html() %}
  39 + <img class="mb-4" src="{{ static_url('logo_horizontal_login.png') }}" alt="Universidade de Évora" height="80">
  40 +
  41 + <div class="form-floating">
  42 + <input type="text" class="form-control" id="uid" name="uid" placeholder="99999" required autofocus>
  43 + <label for="uid">Número de aluno</label>
  44 + </div>
  45 + <div class="form-floating">
  46 + <input type="password" class="form-control" id="pw" name="pw" placeholder="Senha" required>
  47 + <label for="pw">Senha</label>
  48 + </div>
  49 +
  50 + <p class="text-danger"> {{ error }} </p>
  51 +
  52 + <button class="w-100 btn btn-lg btn-primary" type="submit">Entrar</button>
  53 + </form>
  54 + </main>
  55 +
  56 + </body>
54 57 </html>
... ...
aprendizations/templates/maintopics-table.html
1 1 {% autoescape %}
2 2  
3   -<!doctype html>
  3 +<!DOCTYPE html>
4 4 <html lang="pt">
5 5 <head>
6   - <title>{{appname}}</title>
7   - <link rel="icon" href="/static/favicon.ico">
8   -
9   - <meta charset="utf-8">
10   - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  6 + <meta charset="utf-8" />
  7 + <meta name="viewport" content="width=device-width, initial-scale=1" />
11 8 <meta name="author" content="Miguel Barão">
  9 + <link rel="icon" href="/static/favicon.ico">
12 10  
13 11 <!-- Styles -->
14   - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">
15   - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">
16   - <link rel="stylesheet" href="/static/css/maintopics.css">
  12 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  13 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
17 14  
18 15 <!-- Scripts -->
19   - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>
20   - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>
21   - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>
22   - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>
23   - <script defer src="/static/fontawesome-free/js/all.min.js"></script>
24   - <script defer src="/static/js/maintopics.js"></script>
  16 + <script src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  17 + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
25 18  
  19 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  20 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
  21 +
  22 + <title>{{appname}}</title>
26 23 </head>
27 24 <!-- ===================================================================== -->
28 25 <body>
29   -<nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">
30   - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">
31   - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
32   - <span class="navbar-toggler-icon"></span>
33   - </button>
34   -
35   - <div class="collapse navbar-collapse" id="navbarText">
36   - <div class="navbar-nav mr-auto">
37   - <a class="nav-item nav-link" href="/courses">Cursos</a>
38   - <a class="nav-item nav-link active" href="#">Tópicos <span class="sr-only">(actual)</span></a>
39   - <a class="nav-item nav-link" href="/rankings?course={{course_id}}">Classificação</a>
  26 +<nav class="navbar navbar-expand-sm navbar-dark bg-primary fixed-top shadow">
  27 + <div class="container-fluid">
  28 + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora">
  29 + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  30 + <span class="navbar-toggler-icon"></span>
  31 + </button>
  32 +
  33 + <div class="collapse navbar-collapse" id="navbarNavText">
  34 + <ul class="navbar-nav">
  35 + <li class="nav-item"><a class="nav-link" href="/courses">Cursos</a></li>
  36 + <li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Tópicos</a></li>
  37 + <li class="nav-item"><a class="nav-link" href="/rankings?course={{course_id}}">Classificação</a></li>
  38 + </ul>
  39 + <ul class="navbar-nav ms-auto">
  40 + <li class="nav-item dropdown">
  41 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  42 + <i class="fas fa-user-graduate" aria-hidden="true"></i>
  43 + &nbsp;
  44 + <span id="name">{{ escape(name) }}</span>
  45 + <span class="caret"></span>
  46 + </a>
  47 + <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
  48 + <li><a class="dropdown-item" data-bs-toggle="modal" data-bs-target="#password_modal">Mudar Password</a></li>
  49 + <li><hr class="dropdown-divider"></li>
  50 + <li><a class="dropdown-item" href="/logout">Sair</a></li>
  51 + </ul>
  52 + </li>
  53 + </ul>
40 54 </div>
  55 + </div>
  56 +</nav>
41 57  
42   - <ul class="navbar-nav">
43   - <li class="nav-item dropdown">
44   - <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
45   - <i class="fas fa-user-graduate" aria-hidden="true"></i>
46   - <span id="name">{{ escape(name) }}</span>
47   - <span class="caret"></span>
48   - </a>
49   - <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
50   - <a class="dropdown-item" data-toggle="modal" data-target="#password_modal">Mudar Password</a>
51   - <div class="dropdown-divider"></div>
52   - <a class="dropdown-item" href="/logout">Sair</a>
  58 +<!-- === Change Password Modal =========================================== -->
  59 +<div id="password_modal" class="modal fade" tabindex="-1" aria-labelledby="password_modal" aria-hidden="true">
  60 + <div class="modal-dialog">
  61 + <div class="modal-content">
  62 + <!-- header -->
  63 + <div class="modal-header">
  64 + <h5 class="modal-title">Alterar Password</h5>
  65 + <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
  66 + </div>
  67 + <!-- body -->
  68 + <div class="modal-body">
  69 + <div class="control-group">
  70 + <label for="new_password" class="control-label">Introduza a nova password:</label>
  71 + <div class="controls">
  72 + <input type="password" id="new_password" name="new_password" autocomplete="new-password">
  73 + </div>
53 74 </div>
54   - </li>
55   - </ul>
  75 + </div>
  76 + <!-- footer -->
  77 + <div class="modal-footer">
  78 + <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
  79 + <button id="change_password" type="button" class="btn btn-primary" data-bs-dismiss="modal">Alterar</button>
  80 + </div>
  81 + <!-- end -->
  82 + </div>
56 83 </div>
57   -</nav>
  84 +</div>
  85 +
58 86 <!-- ===================================================================== -->
59 87 <div class="container">
60 88  
61 89 <div id="notifications"></div>
62 90  
63   - <div class="alert alert-warning alert-dismissible fade show" role="warning">
64   - <h5 class="my-3">Legenda:</h5>
65   - <dl class="row ml-3">
66   - <dt class="my-0 col-sm-1"><i class="fas fa-book"></i></dt>
67   - <dd class="my-0 col-sm-11">Material de estudo</dd>
68   - <dt class="my-0 col-sm-1"><i class="fas fa-pencil-alt"></i></dt>
69   - <dd class="my-0 col-sm-11">Exercícios</dd>
70   - <dt class="my-0 col-sm-1"><i class="fas fa-puzzle-piece"></i></dt>
71   - <dd class="my-0 col-sm-11">Não faz parte deste curso mas é necessário saber</dd>
72   - <dt class="my-0 col-sm-1"><i class="fas fa-flag"></i></dt>
73   - <dd class="my-0 col-sm-11">Milestone (terminou um certo conjunto de tópicos)</dd>
74   - </dl>
75   - <button type="button" class="close" data-dismiss="alert" aria-label="Close">
76   - <span aria-hidden="true">&times;</span>
77   - </button>
78   - </div>
79   -
80   - <h1 class="display-5 p-4">{{ course['title'] }}</h1>
  91 + <h1 class="display-6">{{ course['title'] }}</h1>
81 92  
82 93 <table class="table table-hover">
83   - <thead class="">
  94 + <thead>
84 95 <tr>
85   - <th>Tópico</th>
86   - <th class="text-center">Nível</th>
  96 + <th scope="col"></th>
  97 + <th scope="col">Tópico</th>
  98 + <th scope="col" class="text-center">Estado</th>
87 99 </tr>
88 100 </thead>
89 101 <tbody>
90 102 {% for t in state %}
91 103 <!-- ------------------------------------------------------------- -->
92 104 {% if t['level'] is None %}
93   - <tr class="table-secondary">
  105 + <tr>
  106 + <th scope="row" class="text-muted text-center">
  107 + {% if t['type']=='chapter' %}
  108 + <i class="fas fa-flag"></i>
  109 + {% elif t['type']=='learn' %}
  110 + <i class="fas fa-book"></i>&nbsp;
  111 + {% else %}
  112 + <i class="fas fa-pencil-alt"></i>&nbsp;
  113 + {% end %}
  114 + </th>
94 115 <td>
95 116 <div class="text-muted">
96 117 {% if t['ref'] not in course['goals'] %}
97 118 <i class="fas fa-puzzle-piece"></i>
98 119 {% end %}
99 120  
100   - {% if t['type']=='chapter' %}
101   - <i class="fas fa-flag"></i>
102   - {% elif t['type']=='learn' %}
103   - <i class="fas fa-book"></i>&nbsp;
104   - {% else %}
105   - <i class="fas fa-pencil-alt"></i>&nbsp;
106   - {% end %}
107 121  
108 122 {{ t['name'] }}
109 123 </div>
... ... @@ -116,11 +130,7 @@
116 130 {% else %}
117 131  
118 132 <tr class="clickable-row " data-href="/topic/{{t['ref']}}">
119   - <td class="text-primary">
120   - {% if t['ref'] not in course['goals'] %}
121   - <i class="fas fa-puzzle-piece"></i>
122   - {% end %}
123   -
  133 + <th scope="row" class="text-primary text-center">
124 134 {% if t['type']=='chapter' %}
125 135 <i class="fas fa-flag-checkered"></i>&nbsp;
126 136 {% elif t['type']=='learn' %}
... ... @@ -128,6 +138,11 @@
128 138 {% else %}
129 139 <i class="fas fa-pencil-alt"></i>&nbsp;
130 140 {% end %}
  141 + </th>
  142 + <td class="text-primary">
  143 + {% if t['ref'] not in course['goals'] %}
  144 + <i class="fas fa-puzzle-piece"></i>
  145 + {% end %}
131 146  
132 147 {{ t['name'] }}
133 148 </td>
... ... @@ -150,34 +165,5 @@
150 165 </tbody>
151 166 </table>
152 167 </div>
153   -
154   -
155   -<!-- === Change Password Modal =========================================== -->
156   -<div id="password_modal" class="modal fade" tabindex="-1" role="dialog">
157   - <div class="modal-dialog" role="document">
158   - <div class="modal-content">
159   -<!-- header -->
160   - <div class="modal-header">
161   - <h5 class="modal-title">Alterar Password</h5>
162   - </div>
163   -<!-- body -->
164   - <div class="modal-body">
165   - <div class="control-group">
166   - <label for="new_password" class="control-label">Introduza a nova password:</label>
167   - <div class="controls">
168   - <input type="password" id="new_password" name="new_password" autocomplete="new-password">
169   - </div>
170   - </div>
171   - </div>
172   -<!-- footer -->
173   - <div class="modal-footer">
174   - <button type="button" class="btn btn-default" data-dismiss="modal">Cancelar</button>
175   - <button id="change_password" type="button" class="btn btn-danger" data-dismiss="modal">Alterar</button>
176   - </div>
177   -
178   - </div><!-- /.modal-content -->
179   - </div><!-- /.modal-dialog -->
180   -</div><!-- /.modal -->
181   -
182 168 </body>
183 169 </html>
... ...
aprendizations/templates/question-checkbox.html
... ... @@ -3,20 +3,14 @@
3 3  
4 4 {% block answer %}
5 5 <fieldset data-role="controlgroup">
6   - <div class="list-group">
7   - {% for n,opt in enumerate(question['options']) %}
8   - <a class="list-group-item list-group-item-action">
9   - <div class="custom-control custom-checkbox">
10   - <input type="checkbox" class="custom-control-input"
11   - id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
12   - <label for="{{ n }}" class="custom-control-label">
13   - {{ md(opt, strip_p_tag=True) }}
14   - </label>
15   - </div>
16   - </a>
17   - {% end %}
18   - </div>
  6 + <div class="list-group">
  7 + {% for n,opt in enumerate(question['options']) %}
  8 + <label class="list-group-item list-group-item-action">
  9 + <input type="checkbox" class="form-check-input" id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
  10 + {{ md(opt, strip_p_tag=True) }}
  11 + </label>
  12 + {% end %}
  13 + </div>
19 14 </fieldset>
20 15 <input type="hidden" name="qid" value="{{ question['qid'] }}">
21   -
22   -{% end %}
23 16 \ No newline at end of file
  17 +{% end %}
... ...
aprendizations/templates/question-information.html
1 1 {% autoescape %}
2 2  
3   -<h2 class="page-header">{{ question['title'] }}</h4>
  3 +<h1 class="display-6">{{ question['title'] }}</h1>
4 4  
5 5 <div id="text">
6 6 {{ md(question['text']) }}
... ...
aprendizations/templates/question-radio.html
... ... @@ -3,19 +3,16 @@
3 3  
4 4 {% block answer %}
5 5 <fieldset data-role="controlgroup">
6   - <div class="list-group">
7   - {% for n,opt in enumerate(question['options']) %}
8   - <a class="list-group-item list-group-item-action">
9   - <div class="custom-control custom-radio">
10   - <input type="radio" class="custom-control-input"
11   - id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
12   - <label for="{{ n }}" class="custom-control-label">
13   - {{ md(opt, strip_p_tag=True) }}
14   - </label>
15   - </div>
16   - </a>
17   - {% end %}
18   - </div>
  6 + <div class="list-group">
  7 + {% for n,opt in enumerate(question['options']) %}
  8 + <label class="list-group-item list-group-item-action">
  9 + <input type="radio" class="form-check-input" id="{{ n }}" accesskey="{{ n+1 }}" name="answer" value="{{ n }}">
  10 + <label for="{{ n }}" class="custom-control-label">
  11 + {{ md(opt, strip_p_tag=True) }}
  12 + </label>
  13 + </label>
  14 + {% end %}
  15 + </div>
19 16 </fieldset>
20 17 <input type="hidden" name="qid" value="{{ question['qid'] }}">
21   -{% end %}
22 18 \ No newline at end of file
  19 +{% end %}
... ...
aprendizations/templates/question.html
1 1 {% autoescape %}
2 2  
3   -<h2 class="page-header">{{ md(question['title']) }}</h4>
  3 +<h1 class="display-6">{{ md(question['title']) }}</h1>
4 4  
5 5 <div id="text">
6 6 {{ md(question['text']) }}
... ... @@ -8,4 +8,4 @@
8 8  
9 9 {% block answer %}{% end %}
10 10  
11   -<p class="text-right font-italic" id="tries"></p>
  11 +<p class="text-end"><em id="tries"></em></p>
... ...
aprendizations/templates/rankings.html
1 1 {% autoescape %}
2 2  
3   -<!doctype html>
  3 +<!DOCTYPE html>
4 4 <html lang="pt">
5 5 <head>
6   - <title>{{appname}}</title>
7   - <link rel="icon" href="/static/favicon.ico">
8   -
9   - <meta charset="utf-8">
10   - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
  6 + <meta charset="utf-8" />
  7 + <meta name="viewport" content="width=device-width, initial-scale=1" />
11 8 <meta name="author" content="Miguel Barão">
  9 + <link rel="icon" href="/static/favicon.ico">
  10 + <!-- Styles -->
  11 + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  12 + <link rel="stylesheet" href="{{static_url('css/maintopics.css')}}">
  13 + <!-- Scripts -->
  14 + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
  15 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  16 + <script defer src="{{static_url('js/maintopics.js')}}"></script>
12 17  
13   -<!-- Styles -->
14   - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">
15   - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">
16   - <link rel="stylesheet" href="/static/css/maintopics.css">
17   -
18   -<!-- Scripts -->
19   - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>
20   - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>
21   - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>
22   - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>
23   - <script defer src="/static/fontawesome-free/js/all.min.js"></script>
24   - <script defer src="/static/js/maintopics.js"></script>
25   -
  18 + <title>{{appname}}</title>
26 19 </head>
27 20 <!-- ===================================================================== -->
28 21 <body>
29   -<nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">
30   - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">
31   - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
32   - <span class="navbar-toggler-icon"></span>
33   - </button>
  22 +<nav class="navbar navbar-expand-sm navbar-dark bg-primary fixed-top">
  23 + <div class="container-fluid">
  24 + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora">
  25 + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  26 + <span class="navbar-toggler-icon"></span>
  27 + </button>
34 28  
35   - <div class="collapse navbar-collapse" id="navbarText">
36   - <div class="navbar-nav mr-auto">
37   - <a class="nav-item nav-link" href="/courses">Cursos <span class="sr-only">(actual)</span></a>
38   - <a class="nav-item nav-link" href="/course/{{course_id}}">Tópicos</a>
39   - <a class="nav-item nav-link active" href="#">Classificação</a>
  29 + <div class="collapse navbar-collapse" id="navbarNavText">
  30 + <ul class="navbar-nav">
  31 + <li class="nav-item"><a class="nav-link" href="/courses">Cursos</a></li>
  32 + <li class="nav-item"><a class="nav-link" href="/course/{{course_id}}">Tópicos</a></li>
  33 + <li class="nav-item"><a class="nav-link active" aria-current="page" href="#">Classificação</a></li>
  34 + </ul>
  35 + <ul class="navbar-nav ms-auto">
  36 + <li class="nav-item dropdown">
  37 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  38 + <i class="fas fa-user-graduate" aria-hidden="true"></i>
  39 + &nbsp;
  40 + <span id="name">{{ escape(name) }}</span>
  41 + <span class="caret"></span>
  42 + </a>
  43 + <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
  44 + <li><a class="dropdown-item" href="/logout">Sair</a></li>
  45 + </ul>
  46 + </li>
  47 + </ul>
40 48 </div>
41   -
42   - <ul class="navbar-nav">
43   - <li class="nav-item dropdown">
44   - <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
45   - <i class="fas fa-user-graduate" aria-hidden="true"></i>
46   - <span id="name">{{ escape(name) }}</span>
47   - <span class="caret"></span>
48   - </a>
49   - <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
50   - <a class="dropdown-item" data-toggle="modal" data-target="#password_modal">Mudar Password</a>
51   - <div class="dropdown-divider"></div>
52   - <a class="dropdown-item" href="/logout">Sair</a>
53   - </div>
54   - </li>
55   - </ul>
56 49 </div>
57 50 </nav>
  51 +
58 52 <!-- ===================================================================== -->
59 53 <div class="container">
60   -<h4>{{course_title}}</h4>
  54 + <h1 class="display-6">{{ course_title }}</h1>
  55 +
61 56 <table class="table table-hover">
62 57 <col width="100">
63 58 <thead>
64 59 <tr>
65   - <th># Posição</th>
66   - <th>Aluno</th>
67   - <th>Progresso</th>
  60 + <th scope="col" class="text-center">Posição</th>
  61 + <th scope="col">Aluno</th>
  62 + <th scope="col"></th>
  63 + <th scope="col">Progresso</th>
68 64 </tr>
69 65 </thead>
70 66 <tbody>
71 67 {% for i,r in enumerate(rankings) %}
72   - {% if r[0] == uid %}
73   - <tr class="table-primary"> <!-- this is me -->
74   - {% else %}
75   - <tr>
76   - {% end %}
77   - <td class="text-center"> <!-- rank -->
78   - <strong>
79   - {{'<i class="fas fa-crown fa-2x text-warning"></i>' if i==0 else i+1}}
80   - </strong>
81   - </td>
82   - <td> <!-- student name -->
83   - {{ ' '.join(r[1].split()[n] for n in (0,-1)) }}
84   - &nbsp;
85   - {{ '<i class="far fa-thumbs-up text-success" title="Mais de 75% de respostas correctas"></i>' if r[3] > 0.75 else '' }}
86   - {{ '<i class="fas fa-bug" title="Menos de 50% de respostas correctas" ></i>' if 0.0 < r[3] < 0.5 else '' }}
87   - </td>
88   - <td> <!-- progress -->
89   - <div class="progress">
90   - <div class="progress-bar" role="progressbar" style="width: {{100*r[2]}}%;" aria-valuenow="{{round(100*r[2])}}%" aria-valuemin="0" aria-valuemax="100">{{round(100*r[2])}}%</div>
91   - </div>
92   - </td>
93   - </tr>
  68 + <tr class="{{ 'table-primary' if r[0] == uid else '' }}">
  69 + <td class="text-center"> <!-- rank -->
  70 + <strong>
  71 + {{ '<i class="fas fa-crown fa-lg text-warning"></i>' if i==0 else i+1 }}
  72 + </strong>
  73 + </td>
  74 + <td> <!-- student name -->
  75 + {{ ' '.join(r[1].split()[n] for n in (0,-1)) }}
  76 + </td>
  77 + <td> <!-- nice -->
  78 + {{ '<i class="far fa-thumbs-up text-success" title="Mais de 75% de respostas correctas"></i>' if r[3] > 0.75 else '' }}
  79 + </td>
  80 + <td> <!-- progress -->
  81 + <div class="progress" style="height: 24px;">
  82 + <div class="progress-bar" role="progressbar" style="width: {{ 100*r[2] }}%;" aria-valuenow="{{round(100*r[2])}}%" aria-valuemin="0" aria-valuemax="100">{{round(100*r[2])}}%</div>
  83 + </div>
  84 + </td>
  85 + </tr>
94 86 {% end %}
95 87 </tbody>
96 88 </table>
97   -<!-- === Change Password Modal =========================================== -->
98   -
99   -<div id="password_modal" class="modal fade" tabindex="-1" role="dialog">
100   - <div class="modal-dialog" role="document">
101   - <div class="modal-content">
102   -<!-- header -->
103   - <div class="modal-header">
104   - <h5 class="modal-title">Alterar Password</h5>
105   - </div>
106   -<!-- body -->
107   - <div class="modal-body">
108   - <div class="control-group">
109   - <label for="new_password" class="control-label">Introduza a nova password:</label>
110   - <div class="controls">
111   - <input type="password" id="new_password" name="new_password" autocomplete="new-password">
112   - </div>
113   - </div>
114   - </div>
115   -<!-- footer -->
116   - <div class="modal-footer">
117   - <button type="button" class="btn btn-default" data-dismiss="modal">Cancelar</button>
118   - <button id="change_password" type="button" class="btn btn-danger" data-dismiss="modal">Alterar</button>
119   - </div>
120   -
121   - </div><!-- /.modal-content -->
122   - </div><!-- /.modal-dialog -->
123   -</div>
124   -
125   -<!-- /.modal -->
126   -
127 89 </body>
128 90 </html>
... ...
aprendizations/templates/topic.html
1   -<!doctype html>
2   -<html>
3   -
4   -<head>
5   - <title>{{appname}}</title>
  1 +<!DOCTYPE html>
  2 +<html lang="pt-PT">
  3 + <head>
  4 + <meta charset="utf-8" />
  5 + <meta name="viewport" content="width=device-width, initial-scale=1" />
  6 + <meta name="author" content="Miguel Barão" />
6 7 <link rel="icon" href="/static/favicon.ico">
7 8  
8   - <meta charset="utf-8">
9   - <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
10   - <meta name="author" content="Miguel Barão">
  9 + <!-- Styles -->
  10 + <!-- <link rel="stylesheet" href="{{static_url('mdbootstrap/css/bootstrap.min.css')}}"> -->
  11 + <!-- <link rel="stylesheet" href="{{static_url('mdbootstrap/css/mdb.min.css')}}"> -->
  12 +
  13 + <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/css/bootstrap.min.css" integrity="sha384-KyZXEAg3QhqLMpG8r+8fhAXLRk2vvoC2f3B09zVXn8CA5QIVfZOJ3BCsw2P0p/We" crossorigin="anonymous">
  14 + <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" />
  15 +
  16 + <link rel="stylesheet" href="{{static_url('codemirror/lib/codemirror.css')}}">
  17 + <link rel="stylesheet" href="{{static_url('css/github.css')}}">
  18 + <link rel="stylesheet" href="{{static_url('css/topic.css')}}">
11 19  
12 20 <!-- MathJax3 -->
13 21 <script>
14   - MathJax = {
  22 + MathJax = {
15 23 tex: {
16   - inlineMath: [
17   - ['$$$', '$$$'],
18   - ['\\(', '\\)']
19   - ]
  24 + inlineMath: [['$$$', '$$$'], ['\\(', '\\)']]
20 25 },
21 26 svg: {
22   - fontCache: 'global'
  27 + fontCache: 'global'
23 28 }
24   - };
  29 + };
25 30 </script>
26   - <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-svg.js"></script>
27   - <!-- Styles -->
28   - <link rel="stylesheet" href="/static/mdbootstrap/css/bootstrap.min.css">
29   - <link rel="stylesheet" href="/static/mdbootstrap/css/mdb.min.css">
30   - <link rel="stylesheet" href="/static/codemirror/lib/codemirror.css">
31   - <link rel="stylesheet" href="/static/css/animate.min.css">
32   - <link rel="stylesheet" href="/static/css/github.css">
33   - <link rel="stylesheet" href="/static/css/topic.css">
34 31 <!-- Scripts -->
35   - <script defer src="/static/mdbootstrap/js/jquery.min.js"></script>
36   - <script defer src="/static/mdbootstrap/js/popper.min.js"></script>
37   - <script defer src="/static/mdbootstrap/js/bootstrap.min.js"></script>
38   - <script defer src="/static/mdbootstrap/js/mdb.min.js"></script>
39   - <script defer src="/static/fontawesome-free/js/all.min.js"></script>
40   - <script defer src="/static/codemirror/lib/codemirror.js"></script>
41   - <script defer src="/static/js/topic.js"></script>
42   -</head>
43   -<!-- ===================================================================== -->
  32 + <script async type="text/javascript" id="MathJax-script" src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-mml-chtml.js"></script>
  33 +
  34 + <script defer src="https://code.jquery.com/jquery-3.6.0.min.js" integrity="sha256-/xUj+3OJU5yExlq6GSYGSHk7tPXikynS7ogEvDej/m4=" crossorigin="anonymous"></script>
  35 + <script defer src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.0/dist/js/bootstrap.bundle.min.js" integrity="sha384-U1DAWAznBHeqEIlVSCgzq+c9gqGAJn5c/t99JyeKa9xxaYpSvHU5awsuZVVFIhvj" crossorigin="anonymous"></script>
  36 +
  37 + <script defer src="{{static_url('fontawesome-free/js/all.min.js')}}"></script>
  38 + <script defer src="{{static_url('codemirror/lib/codemirror.js')}}"></script>
  39 + <script defer src="{{static_url('js/topic.js')}}"></script>
  40 +
  41 + <title>{{appname}}</title>
  42 + </head>
  43 + <!-- ===================================================================== -->
  44 + <body>
  45 + <!-- Progress bar -->
  46 + <div class="progress fixed-top" style="height: 70px; border-radius: 0px;">
  47 + <div class="progress-bar bg-warning" id="topic_progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 1em;width: 0%"></div>
  48 + </div>
44 49  
45   -<body>
46 50 <!-- Navbar -->
47   - <nav class="navbar navbar-expand-sm fixed-top navbar-dark bg-primary">
48   - <img src="/static/logo_horizontal.png" height="48" width="120" class="navbar-brand" alt="UEvora">
49   - <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
50   - <span class="navbar-toggler-icon"></span>
  51 + <nav class="navbar navbar-expand-sm navbar-dark bg-primary fixed-top shadow">
  52 + <div class="container-fluid">
  53 + <img src="{{static_url('logo_horizontal.png')}}" height="48" width="120" class="navbar-brand" alt="UEvora">
  54 + <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText" aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
  55 + <span class="navbar-toggler-icon"></span>
51 56 </button>
52   - <div class="collapse navbar-collapse" id="navbarText">
53   - <div class="navbar-nav mr-auto">
54   - <a class="nav-item nav-link" href="/courses">Cursos</a>
55   - <a class="nav-item nav-link active" href="/course/{{course_id}}">Tópicos <span class="sr-only">(actual)</span></a>
56   - <a class="nav-item nav-link" href="/rankings?course={{course_id}}">Classificação</a>
57   - </div>
58   - <ul class="navbar-nav">
59   - <li class="nav-item dropdown">
60   - <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
61   - <i class="fas fa-user-graduate" aria-hidden="true"></i>
62   - <span id="name">{{ escape(name) }}</span>
63   - <span class="caret"></span>
64   - </a>
65   - <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdown">
66   - <a class="dropdown-item" href="/logout">Sair</a>
67   - </div>
68   - </li>
69   - </ul>
  57 +
  58 + <div class="collapse navbar-collapse" id="navbarNavText">
  59 + <ul class="navbar-nav">
  60 + <li class="nav-item"><a class="nav-link" href="/courses">Cursos</a></li>
  61 + <li class="nav-item"><a class="nav-link active" aria-current="page" href="/course/{{course_id}}">Tópicos</a></li>
  62 + <!-- <li class="nav-item"><a class="nav-link disabled" href="#">Classificação</a></li> -->
  63 + </ul>
  64 + <ul class="navbar-nav ms-auto">
  65 + <li class="nav-item dropdown">
  66 + <a class="nav-link dropdown-toggle" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
  67 + <i class="fas fa-user-graduate" aria-hidden="true"></i>
  68 + &nbsp;
  69 + <span id="name">{{ escape(name) }}</span>
  70 + <span class="caret"></span>
  71 + </a>
  72 + <ul class="dropdown-menu dropdown-menu-end" aria-labelledby="navbarDropdown">
  73 + <li><a class="dropdown-item" href="/logout">Sair</a></li>
  74 + </ul>
  75 + </li>
  76 + </ul>
70 77 </div>
  78 + </div>
71 79 </nav>
72   - <!-- ===================================================================== -->
73   - <div class="progress">
74   - <div class="progress-bar bg-warning" id="topic_progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100" style="min-width: 1em;width: 0%"></div>
75   - </div>
  80 +
76 81 <!-- ===================================================================== -->
77 82 <!-- main panel with questions -->
78   - <div class="container" id="container">
79   - <div id="notifications"></div>
80   - <div class="my-5" id="content">
81   - <form action="/question" method="post" id="question_form" autocomplete="off">
82   - {% module xsrf_form_html() %}
83   - <div id="question_div"></div>
84   - </form>
85   - <div id="comments"></div>
86   - </div>
87   - <div id="wrong" style="display: none">
88   - <div class="alert alert-danger">
89   - <h4><i class="fas fa-thumbs-down fa-3x"></i> Não acertou, mas também se aprende com os erros...</h4>
90   - <div id="solution_wrong"></div>
91   - </div>
  83 + <div class="container" id="container" style="padding-top: 100px;">
  84 +
  85 + <div id="notifications"></div>
  86 +
  87 + <div class="my-5" id="content">
  88 + <form action="/question" method="post" id="question_form" autocomplete="off">
  89 + {% module xsrf_form_html() %}
  90 + <div id="question_div"></div>
  91 + </form>
  92 + <div id="comments"></div>
  93 + </div>
  94 + <!-- feedback right / wrong -->
  95 + <div id="right" style="display: none">
  96 + <div class="alert alert-success shadow">
  97 + <h4><i class="fas fa-thumbs-up fa-3x"></i> Muito bem!</h4>
  98 + <div id="solution_right"></div>
92 99 </div>
93   - <div id="right" style="display: none">
94   - <div class="alert alert-success">
95   - <h4><i class="fas fa-thumbs-up fa-3x"></i> Muito bem!</h4>
96   - <div id="solution_right"></div>
97   - </div>
  100 + </div>
  101 + <div id="wrong" style="display: none">
  102 + <div class="alert alert-danger shadow">
  103 + <h4><i class="fas fa-thumbs-down fa-3x"></i> Não acertou, mas também se aprende com os erros...</h4>
  104 + <div id="solution_wrong"></div>
98 105 </div>
99   - <!-- reponder / continuar -->
100   - <a class="btn btn-primary btn-lg btn-block my-5" id="submit" data-toggle="tooltip" data-placement="right" href="#solution"></a>
101   - <!-- title="Shift-Enter" -->
  106 + </div>
  107 + <!-- button reponder / continuar -->
  108 + <div class="d-grid gap-2">
  109 + <button type="submit" class="btn btn-primary btn-lg btn-block my-5 shadow bg-gradient" id="submit" data-bs-toggle="button" href="#solution" style="display: none"></button>
  110 + </div>
102 111 </div>
103   -</body>
104   -
105   -</html>
106 112 \ No newline at end of file
  113 + </body>
  114 +</html>
... ...
aprendizations/tools.py
... ... @@ -136,7 +136,7 @@ markdown = MarkdownWithMath(HighlightRenderer(escape=True))
136 136  
137 137 def md_to_html(text: str, strip_p_tag: bool = False) -> str:
138 138 md: str = markdown(text)
139   - if strip_p_tag and md.startswith('<p>') and md.endswith('</p>'):
  139 + if strip_p_tag and md.startswith('<p>') and md.endswith('</p>\n'):
140 140 return md[3:-5]
141 141 else:
142 142 return md
... ... @@ -228,7 +228,7 @@ async def run_script_async(script: str,
228 228 )
229 229  
230 230 try:
231   - stdout, stderr = await asyncio.wait_for(
  231 + stdout, _ = await asyncio.wait_for(
232 232 p.communicate(input=stdin.encode('utf-8')),
233 233 timeout=timeout
234 234 )
... ...
demo/math/multiplication/multiplication-table.py
1 1 #!/usr/bin/env python3
2 2  
3 3 import random
4   -import sys
5 4  
6   -# can be repeated
7   -# x = random.randint(2, 9)
8   -# y = random.randint(2, 9)
9   -
10   -# with x != y
11 5 x, y = random.sample(range(2,10), k=2)
12 6 r = x * y
13 7  
... ... @@ -19,6 +13,7 @@ type: text
19 13 title: Multiplicação (tabuada)
20 14 text: |
21 15 Qual o resultado da multiplicação ${x}\\times {y}$?
  16 +transform: ['trim']
22 17 correct: ['{r}']
23 18 solution: |
24 19 A multiplicação é a repetição da soma. Podemos fazer de duas maneiras:
... ...
demo/math/multiplication/questions.yaml
1 1 ---
2   -# ---------------------------------------------------------------------------
  2 +# ----------------------------------------------------------------------------
3 3 - type: generator
4 4 ref: multiplication-table
5 5 script: multiplication-table.py
6 6  
  7 +# ----------------------------------------------------------------------------
7 8 - type: checkbox
8 9 ref: multiplication-properties
9 10 title: Propriedades da multiplicação
... ... @@ -17,7 +18,7 @@
17 18 # wrong
18 19 - Existência de inverso, todos os números $x$ tem um inverso $1/x$ tal que
19 20 $x(1/x)=1$.
20   - correct: [1, 1, 1, 1, -1]
  21 + correct: [1, 1, 1, 1, 0]
21 22 solution: |
22 23 Na multiplicação nem todos os números têm inverso. Só têm inverso os números
23 24 diferentes de zero.
... ...
mypy.ini
1 1 [mypy]
2   -python_version = 3.7
3   -warn_return_any = True
4   -warn_unused_configs = True
5   -
6   -[mypy-sqlalchemy.*]
7   -ignore_missing_imports = True
  2 +python_version = 3.9
  3 +plugins = sqlalchemy.ext.mypy.plugin
8 4  
9 5 [mypy-pygments.*]
10 6 ignore_missing_imports = True
... ...
package-lock.json
1 1 {
  2 + "name": "aprendizations",
  3 + "lockfileVersion": 2,
2 4 "requires": true,
3   - "lockfileVersion": 1,
  5 + "packages": {
  6 + "": {
  7 + "dependencies": {
  8 + "@fortawesome/fontawesome-free": "^5.15.3",
  9 + "codemirror": "^5.59.4"
  10 + }
  11 + },
  12 + "node_modules/@fortawesome/fontawesome-free": {
  13 + "version": "5.15.4",
  14 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
  15 + "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg==",
  16 + "hasInstallScript": true,
  17 + "engines": {
  18 + "node": ">=6"
  19 + }
  20 + },
  21 + "node_modules/codemirror": {
  22 + "version": "5.62.2",
  23 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.62.2.tgz",
  24 + "integrity": "sha512-tVFMUa4J3Q8JUd1KL9yQzQB0/BJt7ZYZujZmTPgo/54Lpuq3ez4C8x/ATUY/wv7b7X3AUq8o3Xd+2C5ZrCGWHw=="
  25 + }
  26 + },
4 27 "dependencies": {
5 28 "@fortawesome/fontawesome-free": {
6   - "version": "5.12.0",
7   - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.12.0.tgz",
8   - "integrity": "sha512-vKDJUuE2GAdBERaQWmmtsciAMzjwNrROXA5KTGSZvayAsmuTGjam5z6QNqNPCwDfVljLWuov1nEC3mEQf/n6fQ=="
  29 + "version": "5.15.4",
  30 + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz",
  31 + "integrity": "sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg=="
9 32 },
10 33 "codemirror": {
11   - "version": "5.51.0",
12   - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.51.0.tgz",
13   - "integrity": "sha512-vyuYYRv3eXL0SCuZA4spRFlKNzQAewHcipRQCOKgRy7VNAvZxTKzbItdbCl4S5AgPZ5g3WkHp+ibWQwv9TLG7Q=="
14   - },
15   - "mdbootstrap": {
16   - "version": "4.12.0",
17   - "resolved": "https://registry.npmjs.org/mdbootstrap/-/mdbootstrap-4.12.0.tgz",
18   - "integrity": "sha512-+X4x63tE96zpVOcRlVUGdcR65M9Ud+/l1TvdmcwUjEGo3ktn9TO3e6S3DBLTvchO9U5eKuJh/MIWIGac7+569g=="
  34 + "version": "5.62.2",
  35 + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.62.2.tgz",
  36 + "integrity": "sha512-tVFMUa4J3Q8JUd1KL9yQzQB0/BJt7ZYZujZmTPgo/54Lpuq3ez4C8x/ATUY/wv7b7X3AUq8o3Xd+2C5ZrCGWHw=="
19 37 }
20 38 }
21 39 }
... ...
package.json
... ... @@ -2,9 +2,8 @@
2 2 "description": "Javascript libraries required to run the server",
3 3 "email": "mjsb@uevora.pt",
4 4 "dependencies": {
5   - "@fortawesome/fontawesome-free": "^5.12.0",
6   - "codemirror": "^5.51.0",
7   - "mdbootstrap": "^4.12.0"
  5 + "@fortawesome/fontawesome-free": "^5.15.3",
  6 + "codemirror": "^5.59.4"
8 7 },
9 8 "private": true
10 9 }
... ...
setup.py
... ... @@ -18,13 +18,13 @@ setup(
18 18 url="https://git.xdi.uevora.pt/mjsb/aprendizations.git",
19 19 packages=find_packages(),
20 20 include_package_data=True, # install files from MANIFEST.in
21   - python_requires='>=3.7.*',
  21 + python_requires='>=3.8.*',
22 22 install_requires=[
23 23 'tornado>=6.0',
24 24 'mistune',
25 25 'pyyaml>=5.1',
26 26 'pygments',
27   - 'sqlalchemy',
  27 + 'sqlalchemy>=1.4',
28 28 'bcrypt>=3.1',
29 29 'networkx>=2.4'
30 30 ],
... ...