Commit 443a1eead78985ecc296b4c301921bea4c2992cb
1 parent
ea60c949
Exists in
master
and in
1 other branch
Update to latest sqlalchemy 1.4, etc.
Use bootstrap instead of material themed version. Lot's of small changes and fixes.
Showing
32 changed files
with
1870 additions
and
1606 deletions
Show diff stats
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 "questions" 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(',')) |
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) -> 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 |
... | ... | @@ -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
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 | + | |
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 | - / | |
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 | + | |
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">×</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> | |
111 | + {% else %} | |
112 | + <i class="fas fa-pencil-alt"></i> | |
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> | |
104 | - {% else %} | |
105 | - <i class="fas fa-pencil-alt"></i> | |
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> |
126 | 136 | {% elif t['type']=='learn' %} |
... | ... | @@ -128,6 +138,11 @@ |
128 | 138 | {% else %} |
129 | 139 | <i class="fas fa-pencil-alt"></i> |
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
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 | + | |
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 | - | |
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 | + | |
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
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 | ], | ... | ... |