Commit 49f4c7a84e5c4d35ccc67e982a568598a80c044c
Exists in
master
and in
1 other branch
Merge branch 'dev'
Showing
30 changed files
with
1174 additions
and
1520 deletions
Show diff stats
BUGS.md
1 | 1 | |
2 | 2 | # BUGS |
3 | 3 | |
4 | -- show_points nao esta a funcionar (mostra sempre) | |
5 | -- esta a corrigir código JOBE mesmo que nao tenha respondido??? | |
6 | -- correct devia poder ser corrido mais que uma vez (por exemplo para alterar | |
7 | - cotacoes, corrigir perguntas) | |
8 | -- nao esta a mostrar imagens?? internal server error? | |
9 | -- guardar testes em JSON assim que sao atribuidos aos alunos (ou guardados | |
10 | - inicialmente com um certo nome, e atribuidos posteriormente ao aluno). | |
11 | -- cookies existe um `perguntations_user` e um user. De onde vem o user? | |
12 | -- QuestionCode falta reportar nos comments os vários erros que podem ocorrer | |
13 | - (timeout, etc) | |
14 | -- algumas vezes a base de dados guarda o mesmo teste em duplicado. ver se dois | |
15 | - submits dao origem a duas correcções. talvez a base de dados devesse ter | |
16 | - como chave do teste um id que fosse único desse teste particular (não um auto | |
17 | - counter, nem ref do teste) | |
18 | -- em caso de timeout na submissão (e.g. JOBE ou script nao responde) a | |
19 | - correcção não termina e o teste não é guardado. | |
4 | +- correct devia poder ser corrido mais que uma vez (por exemplo para alterar cotacoes, corrigir perguntas) | |
5 | +- guardar testes em JSON assim que sao atribuidos aos alunos (ou guardados inicialmente com um certo nome, e atribuidos posteriormente ao aluno). | |
6 | +- cookies existe um perguntations_user e um user. De onde vem o user? | |
7 | +- QuestionCode falta reportar nos comments os vários erros que podem ocorrer (timeout, etc) | |
8 | +- algumas vezes a base de dados guarda o mesmo teste em duplicado. ver se dois submits dao origem a duas correcções. | |
9 | +talvez a base de dados devesse ter como chave do teste um id que fosse único desse teste particular (não um auto counter, nem ref do teste) | |
10 | +- em caso de timeout na submissão (e.g. JOBE ou script nao responde) a correcção não termina e o teste não é guardado. | |
20 | 11 | - grade gives internal server error?? |
21 | 12 | - reload do teste recomeça a contagem no inicio do tempo. |
22 | -- em admin, quando `scale_max` não é 20, as cores das barras continuam a | |
23 | - reflectir a escala 0,20. a tabela teste na DB não tem a escala desse teste. | |
24 | -- em grade.html as barras estao normalizadas para os limites `scale_min` e max | |
25 | - do teste actual e nao dos testes realizados no passado (tabela test devia | |
26 | - guardar a escala). | |
27 | -- codigo `hello world` nao esta a preservar o whitespace. O renderer de | |
28 | - markdown gera a tag `<code>` que não preserva whitespace. Necessario | |
29 | - adicionar `<pre>.` | |
30 | -- mensagems de erro do assembler aparecem na mesma linha na correcao e nao | |
31 | - fazerm rendering do `$t`, ver se servidor faz parse do markdown dessas | |
32 | - mensagens. | |
13 | +- em admin, quando scale_max não é 20, as cores das barras continuam a reflectir a escala 0,20. a tabela teste na DB não tem a escala desse teste. | |
14 | +- em grade.html as barras estao normalizadas para os limites scale_min e max do teste actual e nao dos testes realizados no passado (tabela test devia guardar a escala). | |
15 | +- codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>. | |
16 | +- mensagems de erro do assembler aparecem na mesma linha na correcao e nao fazerm rendering do `$t`, ver se servidor faz parse do markdown dessas mensagens. | |
33 | 17 | - a revisao do teste não mostra as imagens. |
34 | -- `Test.reset_answers()` unused. | |
18 | +- Test.reset_answers() unused. | |
35 | 19 | - teste nao esta a mostrar imagens de vez em quando.??? |
36 | 20 | - show-ref nao esta a funcionar na correccao (pelo menos) |
37 | 21 | |
38 | 22 | # TODO |
39 | 23 | |
40 | -- permitir que textarea nao tenha correct definido, da sempre 0 e é para | |
41 | - corrigir manualmente. | |
42 | -- permitir corrigir mais que uma vez, actualizando a base de dados. | |
43 | -- yaml schemas para validar os ficheiros yaml (ver cerberus e jsonschema) | |
24 | +- JOBE correct async | |
25 | +- esta a corrigir código JOBE mesmo que nao tenha respondido??? | |
44 | 26 | - permitir remover alunos que estão online para poderem comecar de novo. |
45 | -- guardar nota final grade truncado em zero e sem ser truncado (quando é | |
46 | - necessário fazer correcções à mão às perguntas, é necessário o valor não | |
47 | - truncado) | |
48 | -- stress tests. use [locust](https://locust.io) | |
27 | +- guardar nota final grade truncado em zero e sem ser truncado (quando é necessário fazer correcções à mão às perguntas, é necessário o valor não truncado) | |
28 | +- stress tests. use https://locust.io | |
49 | 29 | - wait for admin to start test. (students can be allowed earlier) |
50 | -- impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito | |
51 | - nos computadores. Obrigar a fazer reset? fazer um copy automaticamente? | |
52 | -- na pagina admin, mostrar com cor vermelha as horas de entrada dos alunos que | |
53 | - ja tenham excedido o tempo | |
54 | -- retornar None quando nao ha alteracoes relativamente à última vez. ou usar | |
55 | - push (websockets?) | |
56 | -- mudar ref do test para `test_id` (ref já é usado nas perguntas) | |
30 | +- impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente? | |
31 | +- na pagina admin, mostrar com cor vermelha as horas de entrada dos alunos que ja tenham excedido o tempo | |
32 | +- retornar None quando nao ha alteracoes relativamente à última vez. | |
33 | +ou usar push (websockets?) | |
34 | +- mudar ref do test para test_id (ref já é usado nas perguntas) | |
57 | 35 | - servidor ntpd no x220 para configurar a data/hora dos portateis dell |
58 | -- sala de espera: autorização dada, mas teste não disponível até que seja dada | |
59 | - ordem para começar. | |
60 | -- alunos com necessidades especiais nao podem ter autosubmit. ter um | |
61 | - `autosubmit_exceptions`: ['123', '456'] | |
36 | +- sala de espera: autorização dada, mas teste não disponível até que seja dada ordem para começar. | |
37 | +- alunos com necessidades especiais nao podem ter autosubmit. ter um autosubmit_exceptions: ['123', '456'] | |
62 | 38 | - submissao fazer um post ajax? |
63 | 39 | - adicionar opcao para eliminar um teste em curso. |
64 | 40 | - enviar resposta de cada pergunta individualmente. |
65 | -- experimentar gerador de svg que inclua no markdown da pergunta e ver se | |
66 | - funciona. | |
67 | -- quando ha varias perguntas para escolher, escolher sucessivamente em vez de | |
68 | - aleatoriamente. | |
41 | +- experimentar gerador de svg que inclua no markdown da pergunta e ver se funciona. | |
42 | +- quando ha varias perguntas para escolher, escolher sucessivamente em vez de aleatoriamente. | |
69 | 43 | - como refrescar a tabela de admin sem fazer reload da pagina? |
70 | -- botao "testar resposta" que valida codigo relativamente a syntax, mas nao | |
71 | - classifica. perguntas devem ter opcao validate: script.py. Aluno pressiona | |
72 | - botao e codigo é enviado para servidor para validação, feedback é mostrado na | |
73 | - pagina de teste. | |
74 | -- test: botao submeter valida se esta online com um post `willing_to_submit`, | |
75 | - se estiver online, mostra mensagem de confirmacao, caso contrario avisa que | |
76 | - nao esta online. | |
44 | +- botao "testar resposta" que valida codigo relativamente a syntax, mas nao classifica. perguntas devem ter opcao validate: script.py. Aluno pressiona botao e codigo é enviado para servidor para validação, feedback é mostrado na pagina de teste. | |
45 | +- test: botao submeter valida se esta online com um post willing_to_submit, se estiver online, mostra mensagem de confirmacao, caso contrario avisa que nao esta online. | |
77 | 46 | - test: Cada pergunta respondida é logo submetida. |
78 | 47 | - test: calculadora javascript. |
79 | 48 | - admin: histograma das notas. |
80 | 49 | - admin: mostrar as horas a que o teste terminou para os testes terminados. |
81 | 50 | - admin: histograma das notas. |
82 | 51 | - admin: mostrar teste gerado para aluno (tipo review). |
83 | -- fazer renderer para formulas com mathjax serverside (mathjax-node) ou usar | |
84 | - katex. | |
52 | +- fazer renderer para formulas com mathjax serverside (mathjax-node) ou usar katex. | |
85 | 53 | - fazer renderer para imagens, com links /file?ref=xpto;name=zzz.jpg |
86 | 54 | - fazer renderer para linguagem assembly mips? |
87 | -- cancelar teste no menu admin. Dado o numero de aluno remove teste e faz | |
88 | - logout do aluno. | |
55 | +- cancelar teste no menu admin. Dado o numero de aluno remove teste e faz logout do aluno. | |
56 | +- mathjax-node: | |
57 | + sudo pkg install node npm | |
58 | + npm install mathjax-node mathjax-node-cli # pacotes em ~/node_modules | |
59 | + node_modules/mathjax-node-cli/bin/tex2svg '\sqrt{x}' | |
60 | + usar isto para gerar svg que passa a fazer parte do texto da pergunta (markdown suporta tags svg?) | |
61 | + fazer funçao tex() que recebe formula e converte para svg. exemplo: | |
62 | + fr'''A formula é {tex("\sqrt{x]}")}''' | |
89 | 63 | - Gerar pdf's com todos os testes no final (pdfkit). |
90 | -- manter registo dos unfocus durante o teste e de qual a pergunta visivel nesse | |
91 | - momento | |
64 | +- manter registo dos unfocus durante o teste e de qual a pergunta visivel nesse momento | |
92 | 65 | - permitir varios testes, aluno escolhe qual o teste que quer fazer. |
93 | 66 | - se ocorrer um erro na correcçao avisar aluno para contactar o professor. |
94 | -- abrir o teste numa janela maximizada e que nao permite que o aluno a | |
95 | - redimensione/mova? | |
96 | -- detectar scroll e enviar posição para servidor (analise de scroll para | |
97 | - detectar copianço? ou simplesmente para analisar como os alunos percorrem o | |
98 | - teste) | |
99 | -- aviso na pagina principal para quem usa browser incompativel ou settings | |
100 | - esquisitos... Apos login pode ser enviado e submetido um exemplo de teste | |
101 | - para verificar se browser consegue submeter? ha alunos com javascript | |
102 | - bloqueado? | |
67 | +- abrir o teste numa janela maximizada e que nao permite que o aluno a redimensione/mova? | |
68 | +- detectar scroll e enviar posição para servidor (analise de scroll para detectar copianço? ou simplesmente para analisar como os alunos percorrem o teste) | |
69 | +- aviso na pagina principal para quem usa browser incompativel ou settings esquisitos... Apos login pode ser enviado e submetido um exemplo de teste para verificar se browser consegue submeter? ha alunos com javascript bloqueado? | |
103 | 70 | - criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput |
104 | -- perguntas para professor corrigir mais tarde. permitir que review possa | |
105 | - alterar as notas | |
71 | +- perguntas para professor corrigir mais tarde. permitir que review possa alterar as notas | |
106 | 72 | - fazer uma calculadora javascript e por no menu. surge como modal |
107 | 73 | |
108 | 74 | # FIXED |
109 | 75 | |
110 | -- JOBE correct async | |
111 | 76 | - testar as perguntas todas no início do teste como o aprendizations. |
112 | -- adicionar identificacao do aluno no jumbotron inicial do teste, para que ao | |
113 | - imprimir para pdf a identificacao do aluno venha escrita no documento. | |
77 | +- adicionar identificacao do aluno no jumbotron inicial do teste, para que ao imprimir para pdf a identificacao do aluno venha escrita no documento. | |
114 | 78 | - internal server error quando em --review, download csv detalhado. |
115 | -- perguntas repetidas (mesma ref) dao asneira, porque a referencia é usada como | |
116 | - chave em varios sitios e as chaves nao podem ser dupplicadas. da asneira | |
117 | - pelo menos na funcao `get_questions_csv`. na base de dados tem de estar | |
118 | - registado tb o numero da pergunta, caso contrario é impossível saber a qual | |
119 | - corresponde. | |
79 | +- perguntas repetidas (mesma ref) dao asneira, porque a referencia é usada como chave em varios sitios e as chaves nao podem ser dupplicadas. | |
80 | + da asneira pelo menos na funcao get_questions_csv. na base de dados tem de estar registado tb o numero da pergunta, caso contrario é impossível saber a qual corresponde. | |
120 | 81 | - mostrar unfocus e window area em /admin |
121 | -- CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta | |
122 | - `<` como tag?) | |
82 | +- CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) | |
123 | 83 | - botao de autorizar desliga-se, fazer debounce. |
124 | 84 | - link na pagina com a nota para voltar ao principio. |
125 | 85 | - default logger config mostrar horas com segundos |
... | ... | @@ -128,45 +88,33 @@ |
128 | 88 | - servidor nao esta a lidar com eventos resize. |
129 | 89 | - sock.bind(sockaddr) OSError: [Errno 48] Address already in use |
130 | 90 | - dizer quanto desconta em cada pergunta de escolha multipla |
131 | -- se houver erros a abrir ficheiros .yaml de perguntas, depois dos testes diz | |
132 | - "No errors found". | |
133 | -- se faltarem files na especificação do teste, o check não detecta e factory | |
134 | - não gera para essas perguntas. | |
91 | +- se houver erros a abrir ficheiros .yaml de perguntas, depois dos testes diz "No errors found". | |
92 | +- se faltarem files na especificação do teste, o check não detecta e factory não gera para essas perguntas. | |
135 | 93 | - nao esta a usar points das perguntas |
136 | 94 | - quando se clica no texto de uma opcao, salta para outro lado na pagina. |
137 | -- suportar cotacao to teste diferente de 20 (e.g. para juntar perguntas em | |
138 | - papel). opcao "points: 18" que normaliza total para 18 em vez de 20. | |
95 | +- suportar cotacao to teste diferente de 20 (e.g. para juntar perguntas em papel). opcao "points: 18" que normaliza total para 18 em vez de 20. | |
139 | 96 | - fazer package para instalar perguntations com pip. |
140 | 97 | - pymips: nao pode executar syscalls do spim. |
141 | 98 | - exception sqlalchemy relacionada com threads. |
142 | 99 | - acrescentar logger.conf que sirva de base. |
143 | -- questions.py textarea has a abspath which does not make sense! why is it | |
144 | - there? not working for perguntations, but seems to work for aprendizations | |
145 | -- textarea foi modificado em aprendizations para receber cmd line args. | |
146 | - corrigir aqui tb. | |
100 | +- questions.py textarea has a abspath which does not make sense! why is it there? not working for perguntations, but seems to work for aprendizations | |
101 | +- textarea foi modificado em aprendizations para receber cmd line args. corrigir aqui tb. | |
147 | 102 | - usar npm para instalar javascript |
148 | 103 | - se aluno entrar com l12345 rebenta. numa funcao get_... ver imagem no ipad |
149 | -- no test3 está contar 1.0 valores numa pergunta do tipo info? acontece para | |
150 | - type: info, e não para type: information | |
151 | -- default correct in checkbox must be 1.0, so that the pairs (right,wrong) | |
152 | - still work with `correct` left undefined. | |
104 | +- no test3 está contar 1.0 valores numa pergunta do tipo info? acontece para type: info, e não para type: information | |
105 | +- default correct in checkbox must be 1.0, so that the pairs (right,wrong) still work with `correct` left undefined. | |
153 | 106 | - textarea com codemirror |
154 | 107 | - decorador para user 0, evita o "if uid==0" em muitas funcoes. |
155 | -- numeric interval deve converter respostas que usam virgulas para pontos | |
156 | - decimais | |
157 | -- `self.testapp.get_json_filename_of_test(test_id)` retorna None quando test_id | |
158 | - nao existe. | |
159 | -- o eventloop está a bloquear. correção do teste é blocking. usar | |
160 | - threadpoolexecutor? | |
161 | -- substituir get_event_loop por get_runnint_loop | |
108 | +- numeric interval deve converter respostas que usam virgulas para pontos decimais | |
109 | +- self.testapp.get_json_filename_of_test(test_id) retorna None quando test_id nao existe. | |
110 | +- o eventloop está a bloquear. correção do teste é blocking. usar threadpoolexecutor? | |
111 | +- substituir get_event_loop por get_runnint_loop (ver https://docs.python.org/3/library/asyncio-eventloop.html) | |
162 | 112 | - review nao esta a funcionar |
163 | -- servir imagens das perguntas não funciona. Necessario passar a ref da | |
164 | - pergunta no link para poder ajustar o path no FileHandler. | |
113 | +- servir imagens das perguntas não funciona. Necessario passar a ref da pergunta no link para poder ajustar o path no FileHandler. | |
165 | 114 | - a primeira coluna da tabela admin deveria estar sempre ordenada. |
166 | 115 | - abortar depois de testar todas as perguntas, caso haja algum erro. |
167 | 116 | - imagens jpg/png nas perguntas. |
168 | -- initdb está a inicializar com passwords iguais aos numeros. deveria ser vazio | |
169 | - para alunos definirem. | |
117 | +- initdb está a inicializar com passwords iguais aos numeros. deveria ser vazio para alunos definirem. | |
170 | 118 | - upgrade popper e fazer link. |
171 | 119 | - mover scripts js para head, com defer. ver todos os templates. |
172 | 120 | - update fontawesome to 5. |
... | ... | @@ -176,8 +124,7 @@ |
176 | 124 | - md_to_html() nao usa o segundo argumento q. pode retirar-se dos templates? |
177 | 125 | - config/logger.yaml ainda é do cherrypy... |
178 | 126 | - uniformizar question.py com a de aprendizations... |
179 | -- qual a diferenca entre md_to_html e md_to_html_review, parece desnecessario | |
180 | - haver dois. | |
127 | +- qual a diferenca entre md_to_html e md_to_html_review, parece desnecessario haver dois. | |
181 | 128 | - converter markdown para mistune. |
182 | 129 | - como alterar configuracao para mostrar logs de debug? |
183 | 130 | - espaco no final das tabelas. |
... | ... | @@ -193,43 +140,31 @@ |
193 | 140 | - text-numeric não está a gerar a pergunta. faltam templates? |
194 | 141 | - testar perguntas warning/warn |
195 | 142 | - qd user 0 faz logout rebenta. |
196 | -- Quando grava JSON do teste deve usar 'path' tal como definido na configuração | |
197 | - e não expandido. Isto porque em OSX /home é /Users e quando se muda de um | |
198 | - sistema para outro não encontra os testes. Assim, usando ~ na configuração | |
199 | - deveria funcionar sempre. | |
200 | -- configuração do teste não joga bem com o do aprendizations. Em particular os | |
201 | - scripts não ficam com o mesmo path!!! | |
143 | +- Quando grava JSON do teste deve usar 'path' tal como definido na configuração e não expandido. Isto porque em OSX /home é /Users e quando se muda de um sistema para outro não encontra os testes. Assim, usando ~ na configuração deveria funcionar sempre. | |
144 | +- configuração do teste não joga bem com o do aprendizations. Em particular os scripts não ficam com o mesmo path!!! | |
202 | 145 | - configurar pf em freebsd, port forward 80 -> 8080. documentacao |
203 | 146 | - barras com notas em grade estão desalinhadas. |
204 | 147 | - erros nos generators devem ser ERROR e não WARNING. |
205 | -- se directorio "logs" não existir no directorio actual aborta com mensagem de | |
206 | - erro. | |
207 | -- se um teste tiver a mesma pergunta repetida (ref igual), rebenta na | |
208 | - correcçao. As respostas são agregadas numa lista para cada ref. Ex: {'ref1': | |
209 | - 'resposta1', 'ref2': ['resposta2a', 'resposta2b']} | |
210 | -- usar [](http://fontawesome.io/examples/) em vez dos do bootstrap3 | |
148 | +- se directorio "logs" não existir no directorio actual aborta com mensagem de erro. | |
149 | +- se um teste tiver a mesma pergunta repetida (ref igual), rebenta na correcçao. As respostas são agregadas numa lista para cada ref. Ex: {'ref1': 'resposta1', 'ref2': ['resposta2a', 'resposta2b']} | |
150 | +- usar http://fontawesome.io/examples/ em vez dos do bootstrap3 | |
211 | 151 | - se pergunta tiver 'type:' errado, rebenta. |
212 | 152 | - se submeter um teste so com information, da divisao por zero. |
213 | -- se save_answers nao existir, da warning que nao serao gravados, mas sao | |
214 | - sempre gravados! pagina de administracao diz --not being saved-- | |
153 | +- se save_answers nao existir, da warning que nao serao gravados, mas sao sempre gravados! pagina de administracao diz --not being saved-- | |
215 | 154 | - first login é INFO e não WARNING |
216 | 155 | - /review não mostra imagens porque precisa que teste esteja a decorrer... |
217 | 156 | - visualizar um teste ja realizado na página de administração |
218 | -- Depois da correcção, mostra testes realizados que não foram realizados pelo | |
219 | - próprio | |
220 | -- detectar se janela perde focus e alertar o prof | |
221 | -- server nao esta a receber eventos focus/blur dos utilizadores diferentes de | |
222 | - '0', estranho... | |
157 | +- Depois da correcção, mostra testes realizados que não foram realizados pelo próprio | |
158 | +- detectar se janela perde focus e alertar o prof (http://stackoverflow.com/questions/1060008/is-there-a-way-to-detect-if-a-browser-window-is-not-currently-active) | |
159 | +- server nao esta a receber eventos focus/blur dos utilizadores diferentes de '0', estranho... | |
223 | 160 | - permitir adicionar imagens nas perguntas. |
224 | 161 | - detect_unfocus.js so funciona se estiver inline no html. porquê??? |
225 | 162 | - inserir novo aluno /admin não fecha. |
226 | 163 | - se aluno desistir, ainda fica marcado como online |
227 | 164 | - give dá None em vez de 0.0 |
228 | 165 | - debug mode: log levels not working |
229 | -- Se aluno fizer logout, o teste não é gravado e ficamos sem registo do teste | |
230 | - que o aluno viu. | |
231 | -- criar sqlalchemy sessions dentro de app de modo a estarem associadas a | |
232 | - requests. ver se é facil usar with db:(...) para criar e fechar sessão. | |
166 | +- Se aluno fizer logout, o teste não é gravado e ficamos sem registo do teste que o aluno viu. | |
167 | +- criar sqlalchemy sessions dentro de app de modo a estarem associadas a requests. ver se é facil usar with db:(...) para criar e fechar sessão. | |
233 | 168 | - sqlalchemy queixa-se de threads. |
234 | 169 | - SQLAlchemy em vez da classe database. |
235 | 170 | - replace sys.exit calls |
... | ... | @@ -238,21 +173,20 @@ |
238 | 173 | - configuracao dos logs cherrypy para se darem bem com os outros |
239 | 174 | - browser e ip usados gravado no test. |
240 | 175 | - botões allow all/deny all. |
241 | -- mostrar botão de reset apenas no final da pagina, com edit para escrever o | |
242 | - número. | |
243 | -- aluno faz login, mas fecha browser, ficando no estado (online,deny). Ao | |
244 | - tentar login com outro browser está deny e o prof não consegue pô-lo em allow | |
245 | - pois já não está na lista. => solucao é manter todos os alunos numa tabela. | |
246 | -- pagina de login nao esta a apresentar bem. parece que precisa de autorizacao | |
247 | - para aceder a /static... | |
176 | +- mostrar botão de reset apenas no final da pagina, com edit para escrever o número. | |
177 | +- aluno faz login, mas fecha browser, ficando no estado (online,deny). Ao tentar login com outro browser está deny e o prof não consegue pô-lo em allow pois já não está na lista. => solucao é manter todos os alunos numa tabela. | |
178 | +- pagina de login nao esta a apresentar bem. parece que precisa de autorizacao para aceder a /static... | |
248 | 179 | - Não mostrar Professor nos activos em /admin |
249 | 180 | - /admin mostrar actualizações automaticamente? |
250 | 181 | - se no teste uma das "ref" nao existir nos ficheiros de perguntas, rebenta. |
251 | -- alunos podem estar online, mas browser perder sessao => nao conseguem mais | |
252 | - entrar porque a App pensa que estão online. Permitir login e dar o mesmo | |
253 | - teste. | |
254 | -- pagina de management dos alunos. mostrar online ordenados por hora de login, | |
255 | - offline por número. permitir reset da pw e allow/disallow | |
182 | +- alunos podem estar online, mas browser perder sessao => nao conseguem mais entrar porque a App pensa que estão online. Permitir login e dar o mesmo teste. | |
183 | +- pagina de management dos alunos. | |
184 | + mostrar online ordenados por hora de login, offline por número. | |
185 | + permitir reset da pw e allow/disallow | |
256 | 186 | - script de correcção pode enviar dicionario yaml com grade e comentarios. ex: |
257 | - grade: 0.5 comments: Falhou na função xpto. os comentários são guardados no | |
258 | - teste (ficheiro) ou enviados para o browser no modo practice. | |
187 | + grade: 0.5 | |
188 | + comments: Falhou na função xpto. | |
189 | + os comentários são guardados no teste (ficheiro) ou enviados para o browser no modo practice. | |
190 | +- testar regex na definicao das perguntas. como se faz rawstring em yaml? | |
191 | + singlequote? problemas de backslash??? sim... necessário fazer \\ em varios casos, mas não é claro! e.g. \n é convertido em espaço mas \w é convertido em \\ e w. Solução (http://stackoverflow.com/questions/10771163/python-interpreting-a-regex-from-a-yaml-config-file) é fazer | |
192 | + correct: !regex '^(yes|no)' | ... | ... |
README.md
... | ... | @@ -11,26 +11,27 @@ |
11 | 11 | |
12 | 12 | ## Requirements |
13 | 13 | |
14 | -The webserver is a python application that requires `>=python3.7` and the package installer for python `pip`. The node package management `npm` is also necessary in order to install the javascript libraries. | |
14 | +The webserver is a python application that requires `>=python3.8` and the | |
15 | +package installer for python `pip`. The node package management `npm` is also | |
16 | +necessary in order to install the javascript libraries. | |
15 | 17 | |
16 | 18 | ```sh |
17 | 19 | sudo apt install python3 python3-pip npm # Ubuntu |
18 | -sudo pkg install python37 py37-sqlite3 py37-pip npm # FreeBSD | |
19 | -sudo port install python37 py37-pip py37-setuptools npm6 # MacOS (macports) | |
20 | +sudo pkg install python38 py38-sqlite3 py38-pip npm # FreeBSD | |
21 | +sudo port install python38 py38-pip py38-setuptools npm6 # MacOS (macports) | |
20 | 22 | ``` |
21 | 23 | |
22 | -To make the `pip` install packages to a local directory, the file `pip.conf` should be configured as follows: | |
24 | +To make the `pip` install packages to a local directory, the file `pip.conf` | |
25 | +should be configured as follows: | |
23 | 26 | |
24 | 27 | ```ini |
25 | 28 | [global] |
26 | 29 | user=yes |
27 | - | |
28 | -[list] | |
29 | -format=columns | |
30 | 30 | ``` |
31 | 31 | |
32 | 32 | This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in |
33 | -`~/Library/Application Support/pip/`. You may have to create it, if it doesn't exist yet. | |
33 | +`~/Library/Application Support/pip/`. You may have to create it, if it doesn't | |
34 | +exist yet. | |
34 | 35 | |
35 | 36 | --- |
36 | 37 | |
... | ... | @@ -42,19 +43,21 @@ Download and install: |
42 | 43 | git clone https://git.xdi.uevora.pt/mjsb/perguntations.git |
43 | 44 | cd perguntations |
44 | 45 | npm install |
45 | -pip3 install . | |
46 | +pip install . | |
46 | 47 | ``` |
47 | 48 | |
48 | -The command `npm` installs the javascript libraries and then `pip3` installs the python webserver. This will also install any required dependencies. | |
49 | +The command `npm` installs the javascript libraries and then `pip` installs the | |
50 | +python webserver. This will also install any required dependencies. | |
49 | 51 | |
50 | -**Atention!** `pip3 install .` must run **after** `npm install`, otherwise the javascript libraries will not be found during the install. | |
52 | +**Atention!** `pip install .` must be run **after** `npm install`, otherwise | |
53 | +the javascript libraries will not be found during the install. | |
51 | 54 | |
52 | 55 | --- |
53 | 56 | |
54 | 57 | ## Setup |
55 | 58 | |
56 | -The server will run a `https` server and requires valid certificates. | |
57 | -There are two possibilities to generate the certificates: | |
59 | +The server will run a `https` server and requires valid certificates. There | |
60 | +are two possibilities to generate the certificates: | |
58 | 61 | |
59 | 62 | - public server with static IP address and registered domain name; |
60 | 63 | - private server on a local network isolated from the internet. |
... | ... | @@ -74,38 +77,35 @@ cd ~/.local/share/certs |
74 | 77 | openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -nodes |
75 | 78 | ``` |
76 | 79 | |
77 | -To have a true certificate, like the ones issued by LetsEncrypt, the host | |
78 | -must have a registered domain name. | |
79 | -See instructions for FreeBSD at the end of this page. | |
80 | +To have a true certificate, like the ones issued by LetsEncrypt, the host must | |
81 | +have a registered domain name. See instructions for FreeBSD at the end of this | |
82 | +page. | |
80 | 83 | |
81 | 84 | --- |
82 | 85 | |
83 | 86 | ## Running a demo |
84 | 87 | |
85 | -The directory `demo` in the repository includes a demo test that can be used | |
86 | -as a template for your own tests and questions. | |
87 | - | |
88 | -To run the demonstration test you need to initialize the database using one of | |
89 | -the following methods: | |
88 | +The directory `demo` in the repository includes a demo test that can be used as | |
89 | +a template for your own tests and questions. To run the demonstration test you | |
90 | +need to initialize the database using one of the following methods: | |
90 | 91 | |
91 | 92 | ```sh |
92 | 93 | cd perguntations/demo |
93 | - | |
94 | 94 | initdb students.csv # initialize from a CSV file |
95 | 95 | initdb --admin # only adds the administrator account |
96 | 96 | initdb --add 123 "Asterix Gaules" # add one student |
97 | 97 | ``` |
98 | 98 | |
99 | 99 | This will create or update a `students.db` file that contains a sqlite3 |
100 | -database. | |
101 | -The database stores user passwords and grades, but not the actual tests. | |
100 | +database. The database stores user passwords and grades, but not the actual | |
101 | +tests. | |
102 | 102 | |
103 | -A test is specified in a single `yaml` file. | |
104 | -The demo already includes the `demo.yaml` that you can play with. | |
103 | +A test is specified in a single `yaml` file. The file `demo.yaml` is a test | |
104 | +specification that you can play with. | |
105 | 105 | |
106 | -The complete tests submitted by the students are stored in JSON files in the | |
107 | -directory defined in `demo.yaml` under the option `answers_dir: ans`. | |
108 | -We also have to create this directory manually: | |
106 | +The answered tests submitted by the students are stored in JSON files in a | |
107 | +directory defined in `demo.yaml` under the option `answers_dir: ans`. We also | |
108 | +have to create this directory manually: | |
109 | 109 | |
110 | 110 | ```sh |
111 | 111 | mkdir ans # directory where the tests will be saved |
... | ... | @@ -114,38 +114,36 @@ mkdir ans # directory where the tests will be saved |
114 | 114 | Start the server and run the `demo.yaml` test: |
115 | 115 | |
116 | 116 | ```sh |
117 | -perguntations demo.yaml # run demo test | |
117 | +perguntations demo.yaml # run demo test | |
118 | 118 | ``` |
119 | 119 | |
120 | 120 | Several options are available, run `perguntations --help` for a list. |
121 | 121 | |
122 | -The server listens on port 8443 of all IPs of all network interfaces. | |
123 | -Open the browser at `http://127.0.0.1:8443/` and login as user number `0` | |
122 | +The server listens on port 8443 of all IPs of all network interfaces. Open the | |
123 | +browser at `http://127.0.0.1:8443/` and login as user number `0` | |
124 | 124 | (administrator) and choose any password you want. The password is defined on |
125 | 125 | first login. |
126 | + | |
126 | 127 | After logging in, you will be redirected to the administration page that shows |
127 | 128 | all the students and their current state. |
128 | 129 | |
129 | 130 | 1. Authorize students by clicking the checkboxes. |
130 | 131 | 2. Open a different browser (or exit administrator) at `http://127.0.0.1:8443/` |
131 | -and login as one of the authorized students. | |
132 | -Answer the questions and submit. You should get a grade at the end. | |
132 | + and login as one of the authorized students. Answer the questions and | |
133 | + submit. You should get a grade at the end. | |
133 | 134 | |
134 | -The server can be stoped from the terminal with `^C`. | |
135 | +The server can be stoped from the terminal with `^C` (Control+C). | |
135 | 136 | |
136 | 137 | --- |
137 | 138 | |
138 | 139 | ## Running on lower ports |
139 | 140 | |
140 | 141 | Ports 80 and 443 are reserved for the root user and and this software **should |
141 | -NOT be run as root**. | |
142 | -Instead, tcp traffic can be forwarded from port 443 to 8443 where the server is | |
143 | -listening. | |
144 | -The details depend on the operating system/firewall. | |
145 | - | |
146 | -### debian | |
142 | +NOT be run as root**. Instead, tcp traffic can be forwarded from port 443 to | |
143 | +8443 where the server is listening. The details depend on the operating | |
144 | +system/firewall. | |
147 | 145 | |
148 | -FIXME: Untested | |
146 | +### debian (FIXME: Untested) | |
149 | 147 | |
150 | 148 | ```.sh |
151 | 149 | iptables -t nat -I PREROUTING -i eth0 -p tcp --dport 443 -j REDIRECT --to-ports 8443 |
... | ... | @@ -166,25 +164,29 @@ Explanation: |
166 | 164 | |
167 | 165 | Edit `/etc/pf.conf`: |
168 | 166 | |
169 | - ext_if="wlan1" | |
170 | - rdr on $ext_if proto tcp from any to any port 80 -> 127.0.0.1 port 8080 | |
171 | - rdr on $ext_if proto tcp from any to any port 443 -> 127.0.0.1 port 8443 | |
167 | +```conf | |
168 | +ext_if="wlan1" | |
169 | +rdr on $ext_if proto tcp from any to any port 80 -> 127.0.0.1 port 8080 | |
170 | +rdr on $ext_if proto tcp from any to any port 443 -> 127.0.0.1 port 8443 | |
171 | +``` | |
172 | 172 | |
173 | -The `wlan1` should be the name of the network interface. | |
174 | -Use `ext_if="vtnet0"` for guest additions under virtual box. | |
173 | +The `wlan1` should be the name of the network interface. Use `ext_if="vtnet0"` | |
174 | +for guest additions under virtual box. | |
175 | 175 | |
176 | 176 | Start firewall with `sudo service pf start`. |
177 | 177 | |
178 | 178 | Optionally, to activate pf on boot, edit `rc.conf`: |
179 | 179 | |
180 | - pf_enable="YES" | |
181 | - pf_flags="" | |
182 | - pf_rules="/etc/pf.conf" | |
180 | +```conf | |
181 | +pf_enable="YES" | |
182 | +pf_flags="" | |
183 | +pf_rules="/etc/pf.conf" | |
183 | 184 | |
184 | - # optional logging: | |
185 | - pflog_enable="YES" | |
186 | - pflog_flags="" | |
187 | - pflog_logfile="/var/log/pflog" | |
185 | +# optional logging: | |
186 | +pflog_enable="YES" | |
187 | +pflog_flags="" | |
188 | +pflog_logfile="/var/log/pflog" | |
189 | +``` | |
188 | 190 | |
189 | 191 | ## Generating certificates with LetsEncript (FreeBSD) |
190 | 192 | |
... | ... | @@ -192,7 +194,7 @@ Generating certificates for a public server (FreeBSD) requires a registered |
192 | 194 | domain with fixed IP. |
193 | 195 | |
194 | 196 | ```sh |
195 | -sudo pkg install py27-certbot # FreeBSD | |
197 | +sudo pkg install py38-certbot # FreeBSD 13 | |
196 | 198 | sudo service pf stop # disable pf firewall (FreeBSD) |
197 | 199 | sudo certbot certonly --standalone -d www.example.com |
198 | 200 | sudo service pf start # enable pf firewall |
... | ... | @@ -208,47 +210,52 @@ chmod 400 cert.pem privkey.pem |
208 | 210 | Certificate renewals can be done as follows: |
209 | 211 | |
210 | 212 | ```sh |
211 | -sudo service pf stop # shutdown firewall | |
213 | +sudo service pf stop # shutdown firewall | |
212 | 214 | sudo certbot renew |
213 | -sudo service pf start # start firewall | |
215 | +sudo service pf start # start firewall | |
214 | 216 | ``` |
215 | 217 | |
216 | -Again, copy certificate files `privkey.pem` and `cert.pem` to `~/.local/share/certs`. | |
218 | +Again, copy certificate files `privkey.pem` and `cert.pem` to | |
219 | +`~/.local/share/certs`. | |
217 | 220 | |
218 | 221 | --- |
219 | 222 | |
220 | 223 | ## Upgrading |
221 | 224 | |
222 | -From time to time there can be updates to perguntations, python packages and javascript libraries. | |
223 | - | |
224 | -Python packages can be upgraded independently of the rest using pip: | |
225 | +From time to time there can be updates to perguntations, python packages and | |
226 | +javascript libraries. Python packages can be upgraded independently of the | |
227 | +rest using pip: | |
225 | 228 | |
226 | 229 | ```sh |
227 | -pip list --outdated # lists upgradable packages | |
230 | +pip list --outdated --user # lists upgradable packages | |
228 | 231 | pip install -U something # upgrade something |
229 | 232 | ``` |
230 | 233 | |
234 | +Attention: don't upgrade blindly. Some upgrades can be incompatible with | |
235 | +perguntations and break things! All upgrades require testing of all the | |
236 | +funcionalities. | |
237 | + | |
231 | 238 | To upgrade perguntations and javascript libraries do: |
232 | 239 | |
233 | 240 | ```sh |
234 | 241 | cd perguntations |
235 | 242 | git pull # get latest version of perguntations |
236 | 243 | npm update # get latest versions of javascript libraries |
237 | -pip3 install -U . # upgrade perguntations | |
244 | +pip install -U . # upgrade perguntations | |
238 | 245 | ``` |
239 | 246 | |
240 | 247 | ## Troubleshooting |
241 | 248 | |
242 | 249 | - The server tries to run `python3` so this command must be accessible from |
243 | -user accounts. Currently, the minimum supported python version is 3.7. | |
244 | - | |
250 | + user accounts. Currently, the minimum supported python version is 3.8. | |
245 | 251 | - If you are getting any `UnicodeEncodeError` type of errors that's because the |
246 | -terminal is not supporting UTF-8. | |
247 | -This error may occur when a unicode character is printed to the screen by the | |
248 | -server or, when running question generator or correction scripts, a message is | |
249 | -piped between the server and the scripts that includes unicode characters. | |
250 | -Try running `locale` on the terminal and see if there are any error messages. | |
251 | -Solutions: | |
252 | + terminal is not supporting UTF-8. This error may occur when a unicode | |
253 | + character is printed to the screen by the server or, when running question | |
254 | + generator or correction scripts, a message is piped between the server and | |
255 | + the scripts that includes unicode characters. Try running `locale` on the | |
256 | + terminal and see if there are any error messages. | |
257 | + Solutions: | |
258 | + | |
252 | 259 | - debian: `sudo dpkg-reconfigure locales` and select your UTF-8 locales. |
253 | 260 | - FreeBSD: edit `~/.login_conf` to use UTF-8, for example: |
254 | 261 | ... | ... |
demo/demo.yaml
... | ... | @@ -14,9 +14,6 @@ database: students.db |
14 | 14 | # Directory where the submitted and corrected test are stored for later review. |
15 | 15 | answers_dir: ans |
16 | 16 | |
17 | -# Server used to compile & execute code | |
18 | -jobe_server: 192.168.1.85 | |
19 | - | |
20 | 17 | # --- optional settings: ----------------------------------------------------- |
21 | 18 | |
22 | 19 | # Title of this test, e.g. course name, year or test number |
... | ... | @@ -41,43 +38,46 @@ autocorrect: true |
41 | 38 | # (default: true) |
42 | 39 | show_points: true |
43 | 40 | |
44 | -# scale final grade to an interval, e.g. [0, 20], keeping the relative weight | |
45 | -# of the points declared in the questions below. | |
41 | +# Scale the points of the questions so that the final grade is in the given | |
42 | +# interval. | |
46 | 43 | # (default: no scaling, just use question points) |
47 | -scale: [0, 5] | |
44 | +scale: [0, 20] | |
48 | 45 | |
49 | 46 | |
50 | 47 | # ---------------------------------------------------------------------------- |
51 | -# Base path applied to the questions files and all the scripts | |
52 | -# including question generators and correctors. | |
53 | -# Either absolute path or relative to current directory can be used. | |
54 | -questions_dir: . | |
55 | - | |
56 | -# (optional) List of files containing questions in yaml format. | |
57 | -# Selected questions will be obtained from these files. | |
58 | -# If undefined, all yaml files in questions_dir are loaded (not recommended). | |
48 | +# Files to import. Each file contains a list of questions in yaml format. | |
59 | 49 | files: |
60 | 50 | - questions/questions-tutorial.yaml |
61 | 51 | |
62 | 52 | # This is the list of questions that will make up the test. |
63 | 53 | # The order is preserved. |
64 | -# There are several ways to define each question (explained below). | |
54 | +# Each question is a dictionary with a question `ref` or a list of `ref`. | |
55 | +# If a list is given, one question will be choosen randomly to each student. | |
56 | +# The `points` for each question is optional and is 1.0 by default for normal | |
57 | +# questions. Informative type of "questions" will have 0.0 points. | |
58 | +# Points are automatically scaled if `scale` key is defined. | |
65 | 59 | questions: |
66 | 60 | - ref: tut-test |
67 | - - tut-questions | |
61 | + - ref: tut-questions | |
62 | + | |
63 | + # these will have 1.0 points | |
64 | + - ref: tut-radio | |
65 | + - ref: tut-checkbox | |
66 | + - ref: tut-text | |
67 | + - ref: tut-text-regex | |
68 | + - ref: tut-numeric-interval | |
68 | 69 | |
69 | - - tut-radio | |
70 | - - tut-checkbox | |
71 | - - tut-text | |
72 | - - tut-text-regex | |
73 | - - tut-numeric-interval | |
70 | + # this question will have 2.0 points | |
74 | 71 | - ref: tut-textarea |
75 | 72 | points: 2.0 |
76 | 73 | |
77 | - - tut-information | |
78 | - - tut-success | |
79 | - - tut-warning | |
80 | - - [tut-alert1, tut-alert2] | |
81 | - - tut-generator | |
82 | - - tut-yamllint | |
83 | - # - tut-code | |
74 | + # these will have 0.0 points: | |
75 | + - ref: tut-information | |
76 | + - ref: tut-success | |
77 | + - ref: tut-warning | |
78 | + | |
79 | + # choose one from the list: | |
80 | + - ref: [tut-alert1, tut-alert2] | |
81 | + | |
82 | + - ref: tut-generator | |
83 | + - ref: tut-yamllint | ... | ... |
... | ... | @@ -0,0 +1,77 @@ |
1 | +#!/usr/bin/env python3 | |
2 | + | |
3 | +''' | |
4 | +Example of a question generator. | |
5 | +Arguments are read from stdin. | |
6 | +''' | |
7 | + | |
8 | +from random import randint | |
9 | +import sys | |
10 | + | |
11 | +# read two arguments from the field `args` specified in the question yaml file | |
12 | +a, b = (int(n) for n in sys.argv[1:]) | |
13 | + | |
14 | +x = randint(a, b) | |
15 | +y = randint(a, b) | |
16 | +r = x + y | |
17 | + | |
18 | +print(f"""--- | |
19 | +type: text | |
20 | +title: Geradores de perguntas | |
21 | +text: | | |
22 | + | |
23 | + As perguntas podem ser estáticas (como as que vimos até aqui), ou serem | |
24 | + geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o | |
25 | + programa deve escrever texto no `stdout` em formato `yaml` tal como os | |
26 | + exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode | |
27 | + também receber argumentos de linha de comando para parametrizar a pergunta. | |
28 | + Aqui está um exemplo de uma pergunta gerada por um script python: | |
29 | + | |
30 | + ```python | |
31 | + #!/usr/bin/env python3 | |
32 | + | |
33 | + from random import randint | |
34 | + import sys | |
35 | + | |
36 | + a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando | |
37 | + | |
38 | + x = randint(a, b) # número inteiro no intervalo a..b | |
39 | + y = randint(a, b) # número inteiro no intervalo a..b | |
40 | + r = x + y # calcula resultado correcto | |
41 | + | |
42 | + print(f'''--- | |
43 | + type: text | |
44 | + title: Contas de somar | |
45 | + text: | | |
46 | + Calcule o resultado de ${{x}} + {{y}}$. | |
47 | + correct: '{{r}}' | |
48 | + solution: | | |
49 | + A solução é {{r}}.''') | |
50 | + ``` | |
51 | + | |
52 | + Este script deve ter permissões para poder ser executado no terminal. | |
53 | + Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que | |
54 | + o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar | |
55 | + que este script deve ser usado para gerar uma pergunta. | |
56 | + | |
57 | + Uma pergunta gerada por um programa externo é declarada com | |
58 | + | |
59 | + ```yaml | |
60 | + - type: generator | |
61 | + ref: gen-somar | |
62 | + script: gen-somar.py | |
63 | + # argumentos opcionais | |
64 | + args: [1, 100] | |
65 | + ``` | |
66 | + | |
67 | + O programa pode receber uma lista de argumentos de linha de comando | |
68 | + declarados em `args`. | |
69 | + | |
70 | + --- | |
71 | + | |
72 | + Calcule o resultado de ${x} + {y}$. | |
73 | + | |
74 | + Os números foram gerados aleatoriamente no intervalo de {a} a {b}. | |
75 | +correct: '{r}' | |
76 | +solution: | | |
77 | + A solução é {r}.""") | ... | ... |
demo/questions/generators/generate-question.py
... | ... | @@ -1,77 +0,0 @@ |
1 | -#!/usr/bin/env python3 | |
2 | - | |
3 | -''' | |
4 | -Example of a question generator. | |
5 | -Arguments are read from stdin. | |
6 | -''' | |
7 | - | |
8 | -from random import randint | |
9 | -import sys | |
10 | - | |
11 | -# read two arguments from the field `args` specified in the question yaml file | |
12 | -a, b = (int(n) for n in sys.argv[1:]) | |
13 | - | |
14 | -x = randint(a, b) | |
15 | -y = randint(a, b) | |
16 | -r = x + y | |
17 | - | |
18 | -print(f"""--- | |
19 | -type: text | |
20 | -title: Geradores de perguntas | |
21 | -text: | | |
22 | - | |
23 | - As perguntas podem ser estáticas (como as que vimos até aqui), ou serem | |
24 | - geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o | |
25 | - programa deve escrever texto no `stdout` em formato `yaml` tal como os | |
26 | - exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode | |
27 | - também receber argumentos de linha de comando para parametrizar a pergunta. | |
28 | - Aqui está um exemplo de uma pergunta gerada por um script python: | |
29 | - | |
30 | - ```python | |
31 | - #!/usr/bin/env python3 | |
32 | - | |
33 | - from random import randint | |
34 | - import sys | |
35 | - | |
36 | - a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando | |
37 | - | |
38 | - x = randint(a, b) # número inteiro no intervalo a..b | |
39 | - y = randint(a, b) # número inteiro no intervalo a..b | |
40 | - r = x + y # calcula resultado correcto | |
41 | - | |
42 | - print(f'''--- | |
43 | - type: text | |
44 | - title: Contas de somar | |
45 | - text: | | |
46 | - Calcule o resultado de ${{x}} + {{y}}$. | |
47 | - correct: '{{r}}' | |
48 | - solution: | | |
49 | - A solução é {{r}}.''') | |
50 | - ``` | |
51 | - | |
52 | - Este script deve ter permissões para poder ser executado no terminal. | |
53 | - Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que | |
54 | - o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar | |
55 | - que este script deve ser usado para gerar uma pergunta. | |
56 | - | |
57 | - Uma pergunta gerada por um programa externo é declarada com | |
58 | - | |
59 | - ```yaml | |
60 | - - type: generator | |
61 | - ref: gen-somar | |
62 | - script: gen-somar.py | |
63 | - # argumentos opcionais | |
64 | - args: [1, 100] | |
65 | - ``` | |
66 | - | |
67 | - O programa pode receber uma lista de argumentos de linha de comando | |
68 | - declarados em `args`. | |
69 | - | |
70 | - --- | |
71 | - | |
72 | - Calcule o resultado de ${x} + {y}$. | |
73 | - | |
74 | - Os números foram gerados aleatoriamente no intervalo de {a} a {b}. | |
75 | -correct: '{r}' | |
76 | -solution: | | |
77 | - A solução é {r}.""") |
demo/questions/questions-tutorial.yaml
... | ... | @@ -20,24 +20,20 @@ |
20 | 20 | database: students.db # base de dados previamente criada com initdb |
21 | 21 | answers_dir: ans # directório onde ficam os testes dos alunos |
22 | 22 | |
23 | - # opcional | |
23 | + # opcionais | |
24 | 24 | duration: 60 # duração da prova em minutos (default: inf) |
25 | 25 | autosubmit: true # submissão automática (default: false) |
26 | 26 | show_points: true # mostra cotação das perguntas (default: true) |
27 | - scale: [0, 20] # limites inferior e superior da escala (default: [0,20]) | |
28 | - scale_points: true # normaliza cotações para a escala definida | |
29 | - jobe_server: moodle-jobe.uevora.pt # server used to compile & execute code | |
30 | - debug: false # mostra informação de debug no browser | |
27 | + scale: [0, 20] # normaliza cotações para o intervalo indicado. | |
28 | + # não normaliza por defeito (default: None) | |
31 | 29 | |
32 | 30 | # -------------------------------------------------------------------------- |
33 | - questions_dir: ~/topics # raíz da árvore de directórios das perguntas | |
34 | - | |
35 | 31 | # Ficheiros de perguntas a importar (relativamente a `questions_dir`) |
36 | 32 | files: |
37 | 33 | - tabelas.yaml |
38 | - - topic_A/questions.yaml | |
39 | - - topic_B/part_1/questions.yaml | |
40 | - - topic_B/part_2/questions.yaml | |
34 | + - topic1/questions.yaml | |
35 | + - topic2/part1/questions.yaml | |
36 | + - topic2/part2/questions.yaml | |
41 | 37 | |
42 | 38 | # -------------------------------------------------------------------------- |
43 | 39 | # Especificação das perguntas do teste e respectivas cotações. |
... | ... | @@ -50,13 +46,10 @@ |
50 | 46 | - ref: pergunta2 |
51 | 47 | points: 2.0 |
52 | 48 | |
53 | - # a cotação é 1.0 por defeito | |
49 | + # por defeinto, a cotação da pergunta é 1.0 valor | |
54 | 50 | - ref: pergunta3 |
55 | 51 | |
56 | - # uma string (não dict), é interpretada como referência | |
57 | - - tabela-auxiliar | |
58 | - | |
59 | - # escolhe aleatoriamente uma das variantes | |
52 | + # escolhe aleatoriamente uma das variantes da pergunta | |
60 | 53 | - ref: [pergunta3a, pergunta3b] |
61 | 54 | points: 0.5 |
62 | 55 | |
... | ... | @@ -96,8 +89,7 @@ |
96 | 89 | text: | |
97 | 90 | Quando o texto da pergunta tem várias linhas, dá jeito usar o símbolo |
98 | 91 | `|` de pipe, para indicar que tudo o que estiver indentado faz parte do |
99 | - texto. | |
100 | - É o caso desta pergunta. | |
92 | + texto. É o caso desta pergunta. | |
101 | 93 | |
102 | 94 | O texto das perguntas é escrito em `markdown` e suporta fórmulas em |
103 | 95 | LaTeX. |
... | ... | @@ -105,8 +97,8 @@ |
105 | 97 | #--------------------------------------------------------------------------- |
106 | 98 | ``` |
107 | 99 | |
108 | - As chaves são usadas para construir o teste e não se podem repetir, mesmo em | |
109 | - ficheiros diferentes. | |
100 | + As chaves são usadas para construir o teste e não se podem repetir, mesmo | |
101 | + em ficheiros diferentes. | |
110 | 102 | De seguida mostram-se exemplos dos vários tipos de perguntas. |
111 | 103 | |
112 | 104 | # ---------------------------------------------------------------------------- |
... | ... | @@ -431,6 +423,10 @@ |
431 | 423 | pode estar previamente preenchida como neste caso (use `answer: texto`). |
432 | 424 | correct: correct/correct-question.py |
433 | 425 | timeout: 5 |
426 | + tests_right: | |
427 | + - 'red green blue' | |
428 | + # tests_wrong: | |
429 | + # - 'blue gray yellow' | |
434 | 430 | |
435 | 431 | # --------------------------------------------------------------------------- |
436 | 432 | - type: information |
... | ... | @@ -504,6 +500,7 @@ |
504 | 500 | return 0; // comentario |
505 | 501 | } |
506 | 502 | ``` |
503 | + | |
507 | 504 | ``` |
508 | 505 | |
509 | 506 | # --------------------------------------------------------------------------- |
... | ... | @@ -586,7 +583,7 @@ |
586 | 583 | # ---------------------------------------------------------------------------- |
587 | 584 | - type: generator |
588 | 585 | ref: tut-generator |
589 | - script: generators/generate-question.py | |
586 | + script: generate-question.py | |
590 | 587 | args: [1, 100] |
591 | 588 | |
592 | 589 | # ---------------------------------------------------------------------------- | ... | ... |
mypy.ini
1 | 1 | [mypy] |
2 | -python_version = 3.7 | |
3 | -# warn_return_any = True | |
4 | -# warn_unused_configs = True | |
5 | - | |
6 | -[mypy-sqlalchemy.*] | |
2 | +python_version = 3.9 | |
7 | 3 | ignore_missing_imports = True |
8 | 4 | |
9 | -[mypy-pygments.*] | |
10 | -ignore_missing_imports = True | |
11 | 5 | |
12 | -[mypy-bcrypt.*] | |
13 | -ignore_missing_imports = True | |
6 | +; [mypy-setuptools.*] | |
7 | +; ignore_missing_imports = True | |
14 | 8 | |
15 | -[mypy-mistune.*] | |
16 | -ignore_missing_imports = True | |
9 | +; [mypy-sqlalchemy.*] | |
10 | +; ignore_missing_imports = True | |
11 | + | |
12 | +; [mypy-pygments.*] | |
13 | +; ignore_missing_imports = True | |
14 | + | |
15 | +; [mypy-mistune.*] | |
16 | +; ignore_missing_imports = True | ... | ... |
package.json
... | ... | @@ -2,12 +2,12 @@ |
2 | 2 | "description": "Javascript libraries required to run the server", |
3 | 3 | "email": "mjsb@uevora.pt", |
4 | 4 | "dependencies": { |
5 | - "@fortawesome/fontawesome-free": "^5.15.1", | |
6 | - "bootstrap": "^4.5", | |
7 | - "codemirror": "^5.58.1", | |
5 | + "@fortawesome/fontawesome-free": "^5.15.3", | |
6 | + "bootstrap": "^4.6.0", | |
7 | + "codemirror": "^5.61.1", | |
8 | 8 | "datatables": "^1.10", |
9 | - "jquery": "^3.5.1", | |
10 | - "mathjax": "^3.1.2", | |
11 | - "underscore": "^1.11.0" | |
9 | + "jquery": "^3.6.0", | |
10 | + "mathjax": "^3.1.4", | |
11 | + "underscore": "^1.13.1" | |
12 | 12 | } |
13 | 13 | } | ... | ... |
perguntations/__init__.py
1 | -# Copyright (C) 2020 Miguel Barão | |
1 | +# Copyright (C) 2022 Miguel Barão | |
2 | 2 | # |
3 | 3 | # THE MIT License |
4 | 4 | # |
... | ... | @@ -32,10 +32,10 @@ proof of submission and for review. |
32 | 32 | ''' |
33 | 33 | |
34 | 34 | APP_NAME = 'perguntations' |
35 | -APP_VERSION = '2021.02.dev1' | |
36 | -APP_DESCRIPTION = __doc__ | |
35 | +APP_VERSION = '2022.01.dev1' | |
36 | +APP_DESCRIPTION = str(__doc__) | |
37 | 37 | |
38 | 38 | __author__ = 'Miguel Barão' |
39 | -__copyright__ = 'Copyright 2020, Miguel Barão' | |
39 | +__copyright__ = 'Copyright 2022, Miguel Barão' | |
40 | 40 | __license__ = 'MIT license' |
41 | 41 | __version__ = APP_VERSION | ... | ... |
perguntations/app.py
1 | 1 | ''' |
2 | -Main application module | |
2 | +File: perguntations/app.py | |
3 | +Description: Main application logic. | |
3 | 4 | ''' |
4 | 5 | |
5 | 6 | |
6 | 7 | # python standard libraries |
7 | 8 | import asyncio |
8 | -from contextlib import contextmanager # `with` statement in db sessions | |
9 | 9 | import csv |
10 | 10 | import io |
11 | 11 | import json |
12 | 12 | import logging |
13 | -from os import path | |
13 | +import os | |
14 | +from typing import Optional | |
14 | 15 | |
15 | 16 | # installed packages |
16 | 17 | import bcrypt |
17 | -from sqlalchemy import create_engine, exc | |
18 | -from sqlalchemy.orm import sessionmaker | |
18 | +from sqlalchemy import create_engine, select | |
19 | +from sqlalchemy.exc import OperationalError, NoResultFound, IntegrityError | |
20 | +from sqlalchemy.orm import Session | |
21 | +import yaml | |
19 | 22 | |
20 | 23 | # this project |
21 | -from perguntations.models import Student, Test, Question | |
22 | -from perguntations.tools import load_yaml | |
23 | -from perguntations.testfactory import TestFactory, TestFactoryException | |
24 | -import perguntations.test | |
25 | -from perguntations.questions import question_from | |
24 | +from .models import Student, Test, Question | |
25 | +from .tools import load_yaml | |
26 | +from .testfactory import TestFactory, TestFactoryException | |
27 | +from .test import Test as TestInstance | |
28 | +from .questions import question_from | |
26 | 29 | |
30 | +# setup logger for this module | |
27 | 31 | logger = logging.getLogger(__name__) |
28 | 32 | |
29 | - | |
30 | -# ============================================================================ | |
31 | -class AppException(Exception): | |
32 | - '''Exception raised in this module''' | |
33 | - | |
34 | - | |
35 | -# ============================================================================ | |
36 | -# helper functions | |
37 | -# ============================================================================ | |
38 | -async def check_password(try_pw, hashed_pw): | |
33 | +async def check_password(password: str, hashed: bytes) -> bool: | |
39 | 34 | '''check password in executor''' |
40 | - try_pw = try_pw.encode('utf-8') | |
41 | 35 | loop = asyncio.get_running_loop() |
42 | - hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, hashed_pw) | |
43 | - return hashed_pw == hashed | |
44 | - | |
36 | + return await loop.run_in_executor(None, bcrypt.checkpw, | |
37 | + password.encode('utf-8'), hashed) | |
45 | 38 | |
46 | -async def hash_password(password): | |
47 | - '''hash password in executor''' | |
39 | +async def hash_password(password: str) -> bytes: | |
40 | + '''get hash for password''' | |
48 | 41 | loop = asyncio.get_running_loop() |
49 | 42 | return await loop.run_in_executor(None, bcrypt.hashpw, |
50 | - password.encode('utf-8'), | |
51 | - bcrypt.gensalt()) | |
43 | + password.encode('utf-8'), bcrypt.gensalt()) | |
52 | 44 | |
45 | +# ============================================================================ | |
46 | +class AppException(Exception): | |
47 | + '''Exception raised in this module''' | |
53 | 48 | |
54 | 49 | # ============================================================================ |
55 | 50 | # main application |
56 | 51 | # ============================================================================ |
57 | 52 | class App(): |
58 | 53 | ''' |
59 | - This is the main application | |
60 | - state: | |
61 | - self.Session | |
62 | - self.online - {uid: | |
63 | - {'student':{...}, 'test': {...}}, | |
64 | - ... | |
65 | - } | |
66 | - self.allowd - {'123', '124', ...} | |
67 | - self.testfactory - TestFactory | |
54 | + Main application | |
68 | 55 | ''' |
69 | 56 | |
70 | 57 | # ------------------------------------------------------------------------ |
71 | - @contextmanager | |
72 | - def _db_session(self): | |
73 | - ''' | |
74 | - helper to manage db sessions using the `with` statement, for example: | |
75 | - with self._db_session() as s: s.query(...) | |
76 | - ''' | |
77 | - session = self.Session() | |
78 | - try: | |
79 | - yield session | |
80 | - session.commit() | |
81 | - except exc.SQLAlchemyError: | |
82 | - logger.error('DB rollback!!!') | |
83 | - session.rollback() | |
84 | - raise | |
85 | - finally: | |
86 | - session.close() | |
58 | + def __init__(self, config): | |
59 | + self.debug = config['debug'] | |
60 | + self._make_test_factory(config['testfile']) | |
61 | + self._db_setup() # setup engine and load all students | |
87 | 62 | |
88 | - # ------------------------------------------------------------------------ | |
89 | - def __init__(self, conf) -> None: | |
90 | - self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...} | |
91 | - self.allowed = set() # '0' is hardcoded to allowed elsewhere | |
92 | - self.unfocus = set() # set of students that have no browser focus | |
93 | - self.area = dict() # {uid: percent_area} | |
94 | - self.pregenerated_tests = [] # list of tests to give to students | |
95 | - | |
96 | - self._make_test_factory(conf) | |
97 | - | |
98 | - # connect to database and check registered students | |
99 | - dbfile = self.testfactory['database'] | |
100 | - database = f'sqlite:///{path.expanduser(dbfile)}' | |
101 | - engine = create_engine(database, echo=False) | |
102 | - self.Session = sessionmaker(bind=engine) | |
103 | - try: | |
104 | - with self._db_session() as sess: | |
105 | - num = sess.query(Student).filter(Student.id != '0').count() | |
106 | - except Exception as exc: | |
107 | - raise AppException(f'Database unusable {dbfile}.') from exc | |
108 | - logger.info('Database "%s" has %s students.', dbfile, num) | |
109 | - | |
110 | - # command line option --allow-all | |
111 | - if conf['allow_all']: | |
112 | - self.allow_all_students() | |
113 | - elif conf['allow_list'] is not None: | |
114 | - self.allow_list(conf['allow_list']) | |
115 | - else: | |
116 | - logger.info('Students not yet allowed to login.') | |
63 | + # FIXME get_event_loop will be deprecated in python3.10 | |
64 | + asyncio.get_event_loop().run_until_complete(self._assign_tests()) | |
117 | 65 | |
118 | - # pre-generate tests for allowed students | |
119 | - if self.allowed: | |
120 | - logger.info('Generating %d tests. May take awhile...', | |
121 | - len(self.allowed)) | |
122 | - self._pregenerate_tests(len(self.allowed)) | |
66 | + # command line options: --allow-all, --allow-list filename | |
67 | + if config['allow_all']: | |
68 | + self.allow_all_students() | |
69 | + elif config['allow_list'] is not None: | |
70 | + self.allow_from_list(config['allow_list']) | |
123 | 71 | else: |
124 | - logger.info('No tests were generated.') | |
72 | + logger.info('Students not allowed to login') | |
125 | 73 | |
126 | - if conf['correct']: | |
74 | + if config['correct']: | |
127 | 75 | self._correct_tests() |
128 | 76 | |
129 | 77 | # ------------------------------------------------------------------------ |
130 | - def _correct_tests(self) -> None: | |
131 | - with self._db_session() as sess: | |
132 | - # Find which tests have to be corrected. | |
133 | - # already corrected tests are not included. | |
134 | - dbtests = sess.query(Test)\ | |
135 | - .filter(Test.ref == self.testfactory['ref'])\ | |
136 | - .filter(Test.state == "SUBMITTED")\ | |
137 | - .all() | |
138 | - | |
139 | - logger.info('Correcting %d tests...', len(dbtests)) | |
140 | - for dbtest in dbtests: | |
141 | - try: | |
142 | - with open(dbtest.filename) as file: | |
143 | - testdict = json.load(file) | |
144 | - except FileNotFoundError: | |
145 | - logger.error('File not found: %s', dbtest.filename) | |
146 | - continue | |
78 | + def _db_setup(self) -> None: | |
79 | + ''' | |
80 | + Create database engine and checks for admin and students | |
81 | + ''' | |
82 | + dbfile = os.path.expanduser(self._testfactory['database']) | |
83 | + logger.debug('Checking database "%s"...', dbfile) | |
84 | + if not os.path.exists(dbfile): | |
85 | + raise AppException('No database, use "initdb" to create') | |
147 | 86 | |
148 | - # creates a class Test with the methods to correct it | |
149 | - # the questions are still dictionaries, so we have to call | |
150 | - # question_from() to produce Question() instances that can be | |
151 | - # corrected. Finally the test can be corrected. | |
152 | - test = perguntations.test.Test(testdict) | |
153 | - test['questions'] = [question_from(q) for q in test['questions']] | |
154 | - test.correct() | |
155 | - logger.info('Student %s: grade = %f', test['student']['number'], test['grade']) | |
87 | + # connect to database and check for admin & registered students | |
88 | + self._engine = create_engine(f'sqlite:///{dbfile}', future=True) | |
89 | + try: | |
90 | + with Session(self._engine, future=True) as session: | |
91 | + query = select(Student.id, Student.name)\ | |
92 | + .where(Student.id != '0') | |
93 | + dbstudents = session.execute(query).all() | |
94 | + session.execute(select(Student).where(Student.id == '0')).one() | |
95 | + except NoResultFound: | |
96 | + msg = 'Database has no administrator (user "0")' | |
97 | + logger.error(msg) | |
98 | + raise AppException(msg) from None | |
99 | + except OperationalError: | |
100 | + msg = f'Database "{dbfile}" unusable' | |
101 | + logger.error(msg) | |
102 | + raise AppException(msg) from None | |
103 | + logger.info('Database has %d students', len(dbstudents)) | |
156 | 104 | |
157 | - # save JSON file (overwriting the old one) | |
158 | - uid = test['student']['number'] | |
159 | - ref = test['ref'] | |
160 | - finish_time = test['finish_time'] | |
161 | - answers_dir = test['answers_dir'] | |
162 | - fname = f'{uid}--{ref}--{finish_time}.json' | |
163 | - fpath = path.join(answers_dir, fname) | |
164 | - test.save_json(fpath) | |
165 | - logger.info('%s saved JSON file.', uid) | |
105 | + self._students = {uid: { | |
106 | + 'name': name, | |
107 | + 'state': 'offline', | |
108 | + 'test': None, | |
109 | + } for uid, name in dbstudents} | |
166 | 110 | |
167 | - # update database | |
168 | - dbtest.grade = test['grade'] | |
169 | - dbtest.state = test['state'] | |
170 | - dbtest.questions = [ | |
171 | - Question( | |
172 | - number=n, | |
173 | - ref=q['ref'], | |
174 | - grade=q['grade'], | |
175 | - comment=q.get('comment', ''), | |
176 | - starttime=str(test['start_time']), | |
177 | - finishtime=str(test['finish_time']), | |
178 | - test_id=test['ref'] | |
179 | - ) | |
180 | - for n, q in enumerate(test['questions']) | |
181 | - ] | |
182 | - logger.info('%s database updated.', uid) | |
111 | + # ------------------------------------------------------------------------ | |
112 | + async def _assign_tests(self) -> None: | |
113 | + '''Generate tests for all students that don't yet have a test''' | |
114 | + logger.info('Generating tests...') | |
115 | + for student in self._students.values(): | |
116 | + if student.get('test', None) is None: | |
117 | + student['test'] = await self._testfactory.generate() | |
118 | + logger.info('Tests assigned to all students') | |
183 | 119 | |
184 | 120 | # ------------------------------------------------------------------------ |
185 | - async def login(self, uid, try_pw, headers=None): | |
186 | - '''login authentication''' | |
187 | - if uid not in self.allowed and uid != '0': # not allowed | |
188 | - logger.warning('"%s" unauthorized.', uid) | |
189 | - return 'unauthorized' | |
190 | - | |
191 | - with self._db_session() as sess: | |
192 | - name, hashed_pw = sess.query(Student.name, Student.password)\ | |
193 | - .filter_by(id=uid)\ | |
194 | - .one() | |
195 | - | |
196 | - if hashed_pw == '': # update password on first login | |
197 | - await self.update_student_password(uid, try_pw) | |
198 | - pw_ok = True | |
199 | - else: # check password | |
200 | - pw_ok = await check_password(try_pw, hashed_pw) # async bcrypt | |
201 | - | |
202 | - if not pw_ok: # wrong password | |
203 | - logger.info('"%s" wrong password.', uid) | |
121 | + async def login(self, uid: str, password: str, headers: dict) -> Optional[str]: | |
122 | + ''' | |
123 | + Login authentication | |
124 | + If successful returns None, else returns an error message | |
125 | + ''' | |
126 | + try: | |
127 | + with Session(self._engine, future=True) as session: | |
128 | + query = select(Student.password).where(Student.id == uid) | |
129 | + hashed = session.execute(query).scalar_one() | |
130 | + except NoResultFound: | |
131 | + logger.warning('"%s" does not exist', uid) | |
132 | + return 'nonexistent' | |
133 | + | |
134 | + if uid != '0' and self._students[uid]['state'] != 'allowed': | |
135 | + logger.warning('"%s" login not allowed', uid) | |
136 | + return 'not_allowed' | |
137 | + | |
138 | + if hashed == '': # set password on first login | |
139 | + await self.set_password(uid, password) | |
140 | + elif not await check_password(password, hashed): | |
141 | + logger.info('"%s" wrong password', uid) | |
204 | 142 | return 'wrong_password' |
205 | 143 | |
206 | 144 | # success |
207 | - self.allowed.discard(uid) # remove from set of allowed students | |
208 | - | |
209 | - if uid in self.online: | |
210 | - logger.warning('"%s" login again from %s (reusing state).', | |
211 | - uid, headers['remote_ip']) | |
212 | - # FIXME invalidate previous login | |
145 | + if uid == '0': | |
146 | + logger.info('Admin login from %s', headers['remote_ip']) | |
213 | 147 | else: |
214 | - self.online[uid] = {'student': { | |
215 | - 'name': name, | |
216 | - 'number': uid, | |
217 | - 'headers': headers}} | |
218 | - logger.info('"%s" login from %s.', uid, headers['remote_ip']) | |
148 | + student = self._students[uid] | |
149 | + student['test'].start(uid) | |
150 | + student['state'] = 'online' | |
151 | + student['headers'] = headers | |
152 | + student['unfocus'] = False | |
153 | + student['area'] = 0.0 | |
154 | + logger.info('"%s" login from %s', uid, headers['remote_ip']) | |
155 | + return None | |
219 | 156 | |
220 | 157 | # ------------------------------------------------------------------------ |
221 | - def logout(self, uid): | |
158 | + async def set_password(self, uid: str, password: str) -> None: | |
159 | + '''change password on the database''' | |
160 | + with Session(self._engine, future=True) as session: | |
161 | + query = select(Student).where(Student.id == uid) | |
162 | + student = session.execute(query).scalar_one() | |
163 | + student.password = await hash_password(password) if password else '' | |
164 | + session.commit() | |
165 | + logger.info('"%s" password updated', uid) | |
166 | + | |
167 | + # ------------------------------------------------------------------------ | |
168 | + def logout(self, uid: str) -> None: | |
222 | 169 | '''student logout''' |
223 | - self.online.pop(uid, None) # remove from dict if exists | |
224 | - logger.info('"%s" logged out.', uid) | |
170 | + student = self._students.get(uid, None) | |
171 | + if student is not None: | |
172 | + # student['test'] = None | |
173 | + student['state'] = 'offline' | |
174 | + student.pop('headers', None) | |
175 | + student.pop('unfocus', None) | |
176 | + student.pop('area', None) | |
177 | + logger.info('"%s" logged out', uid) | |
225 | 178 | |
226 | 179 | # ------------------------------------------------------------------------ |
227 | - def _make_test_factory(self, conf): | |
180 | + def _make_test_factory(self, filename: str) -> None: | |
228 | 181 | ''' |
229 | 182 | Setup a factory for the test |
230 | 183 | ''' |
231 | 184 | |
232 | 185 | # load configuration from yaml file |
233 | - logger.info('Loading test configuration "%s".', conf["testfile"]) | |
234 | 186 | try: |
235 | - testconf = load_yaml(conf['testfile']) | |
236 | - except Exception as exc: | |
237 | - msg = 'Error loading test configuration YAML.' | |
238 | - logger.critical(msg) | |
187 | + testconf = load_yaml(filename) | |
188 | + testconf['testfile'] = filename | |
189 | + except (OSError, yaml.YAMLError) as exc: | |
190 | + msg = f'Cannot read test configuration "{filename}"' | |
191 | + logger.error(msg) | |
239 | 192 | raise AppException(msg) from exc |
240 | 193 | |
241 | - # command line options override configuration | |
242 | - testconf.update(conf) | |
243 | - | |
244 | - # start test factory | |
194 | + # make test factory | |
245 | 195 | logger.info('Running test factory...') |
246 | 196 | try: |
247 | - self.testfactory = TestFactory(testconf) | |
197 | + self._testfactory = TestFactory(testconf) | |
248 | 198 | except TestFactoryException as exc: |
249 | - logger.critical(exc) | |
199 | + logger.error(exc) | |
250 | 200 | raise AppException('Failed to create test factory!') from exc |
251 | 201 | |
252 | 202 | # ------------------------------------------------------------------------ |
253 | - def _pregenerate_tests(self, num): # TODO needs improvement | |
254 | - event_loop = asyncio.get_event_loop() | |
255 | - self.pregenerated_tests += [ | |
256 | - event_loop.run_until_complete(self.testfactory.generate()) | |
257 | - for _ in range(num)] | |
258 | - | |
259 | - # ------------------------------------------------------------------------ | |
260 | - async def get_test_or_generate(self, uid): | |
261 | - '''get current test or generate a new one''' | |
262 | - try: | |
263 | - student = self.online[uid] | |
264 | - except KeyError as exc: | |
265 | - msg = f'"{uid}" is not online. get_test_or_generate() FAILED' | |
266 | - logger.error(msg) | |
267 | - raise AppException(msg) from exc | |
268 | - | |
269 | - # get current test. if test does not exist then generate a new one | |
270 | - if not 'test' in student: | |
271 | - await self._new_test(uid) | |
272 | - | |
273 | - return student['test'] | |
274 | - | |
275 | - def get_test(self, uid): | |
276 | - '''get test from online student or raise exception''' | |
277 | - return self.online[uid]['test'] | |
278 | - | |
279 | - # ------------------------------------------------------------------------ | |
280 | - async def _new_test(self, uid): | |
281 | - ''' | |
282 | - assign a test to a given student. if there are pregenerated tests then | |
283 | - use one of them, otherwise generate one. | |
284 | - the student must be online | |
285 | - ''' | |
286 | - student = self.online[uid]['student'] # {'name': ?, 'number': ?} | |
287 | - | |
288 | - try: | |
289 | - test = self.pregenerated_tests.pop() | |
290 | - except IndexError: | |
291 | - logger.info('"%s" generating new test...', uid) | |
292 | - test = await self.testfactory.generate() | |
293 | - logger.info('"%s" test is ready.', uid) | |
294 | - else: | |
295 | - logger.info('"%s" using a pregenerated test.', uid) | |
296 | - | |
297 | - test.start(student) # student signs the test | |
298 | - self.online[uid]['test'] = test | |
299 | - | |
300 | - # ------------------------------------------------------------------------ | |
301 | - async def submit_test(self, uid, ans): | |
203 | + async def submit_test(self, uid, ans) -> None: | |
302 | 204 | ''' |
303 | 205 | Handles test submission and correction. |
304 | 206 | |
305 | 207 | ans is a dictionary {question_index: answer, ...} with the answers for |
306 | 208 | the complete test. For example: {0:'hello', 1:[1,2]} |
307 | 209 | ''' |
308 | - test = self.online[uid]['test'] | |
210 | + if self._students[uid]['state'] != 'online': | |
211 | + logger.warning('"%s" INVALID SUBMISSION! STUDENT NOT ONLINE', uid) | |
212 | + return | |
309 | 213 | |
310 | 214 | # --- submit answers and correct test |
215 | + logger.info('"%s" submitted %d answers', uid, len(ans)) | |
216 | + test = self._students[uid]['test'] | |
311 | 217 | test.submit(ans) |
312 | - logger.info('"%s" submitted %d answers.', uid, len(ans)) | |
313 | 218 | |
314 | 219 | if test['autocorrect']: |
315 | 220 | await test.correct_async() |
316 | - logger.info('"%s" grade = %g points.', uid, test['grade']) | |
221 | + logger.info('"%s" grade = %g points', uid, test['grade']) | |
317 | 222 | |
318 | 223 | # --- save test in JSON format |
319 | 224 | fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json' |
320 | - fpath = path.join(test['answers_dir'], fname) | |
225 | + fpath = os.path.join(test['answers_dir'], fname) | |
321 | 226 | test.save_json(fpath) |
322 | - logger.info('"%s" saved JSON.', uid) | |
227 | + logger.info('"%s" saved JSON', uid) | |
323 | 228 | |
324 | 229 | # --- insert test and questions into the database |
325 | 230 | # only corrected questions are added |
... | ... | @@ -348,13 +253,62 @@ class App(): |
348 | 253 | for n, q in enumerate(test['questions']) |
349 | 254 | ] |
350 | 255 | |
351 | - with self._db_session() as sess: | |
352 | - sess.add(test_row) | |
353 | - logger.info('"%s" database updated.', uid) | |
256 | + with Session(self._engine, future=True) as session: | |
257 | + session.add(test_row) | |
258 | + session.commit() | |
259 | + logger.info('"%s" database updated', uid) | |
354 | 260 | |
355 | 261 | # ------------------------------------------------------------------------ |
356 | - def get_student_grade(self, uid): | |
357 | - return self.online[uid]['test'].get('grade', None) | |
262 | + def _correct_tests(self) -> None: | |
263 | + with Session(self._engine, future=True) as session: | |
264 | + # Find which tests have to be corrected | |
265 | + query = select(Test) \ | |
266 | + .where(Test.ref == self._testfactory['ref']) \ | |
267 | + .where(Test.state == "SUBMITTED") | |
268 | + dbtests = session.execute(query).scalars().all() | |
269 | + if not dbtests: | |
270 | + logger.info('No tests to correct') | |
271 | + return | |
272 | + | |
273 | + logger.info('Correcting %d tests...', len(dbtests)) | |
274 | + for dbtest in dbtests: | |
275 | + try: | |
276 | + with open(dbtest.filename) as file: | |
277 | + testdict = json.load(file) | |
278 | + except OSError: | |
279 | + logger.error('Failed: %s', dbtest.filename) | |
280 | + continue | |
281 | + | |
282 | + # creates a class Test with the methods to correct it | |
283 | + # the questions are still dictionaries, so we have to call | |
284 | + # question_from() to produce Question() instances that can be | |
285 | + # corrected. Finally the test can be corrected. | |
286 | + test = TestInstance(testdict) | |
287 | + test['questions'] = [question_from(q) for q in test['questions']] | |
288 | + test.correct() | |
289 | + logger.info(' %s: %f', test['student'], test['grade']) | |
290 | + | |
291 | + # save JSON file (overwriting the old one) | |
292 | + uid = test['student'] | |
293 | + test.save_json(dbtest.filename) | |
294 | + logger.debug('%s saved JSON file', uid) | |
295 | + | |
296 | + # update database | |
297 | + dbtest.grade = test['grade'] | |
298 | + dbtest.state = test['state'] | |
299 | + dbtest.questions = [ | |
300 | + Question( | |
301 | + number=n, | |
302 | + ref=q['ref'], | |
303 | + grade=q['grade'], | |
304 | + comment=q.get('comment', ''), | |
305 | + starttime=str(test['start_time']), | |
306 | + finishtime=str(test['finish_time']), | |
307 | + test_id=test['ref'] | |
308 | + ) for n, q in enumerate(test['questions']) | |
309 | + ] | |
310 | + session.commit() | |
311 | + logger.info('Database updated') | |
358 | 312 | |
359 | 313 | # ------------------------------------------------------------------------ |
360 | 314 | # def giveup_test(self, uid): |
... | ... | @@ -366,7 +320,7 @@ class App(): |
366 | 320 | # fields = (test['student']['number'], test['ref'], |
367 | 321 | # str(test['finish_time'])) |
368 | 322 | # fname = '--'.join(fields) + '.json' |
369 | - # fpath = path.join(test['answers_dir'], fname) | |
323 | + # fpath = os.path.join(test['answers_dir'], fname) | |
370 | 324 | # test.save_json(fpath) |
371 | 325 | |
372 | 326 | # # insert test into database |
... | ... | @@ -381,11 +335,11 @@ class App(): |
381 | 335 | # state=test['state'], |
382 | 336 | # comment='')) |
383 | 337 | |
384 | - # logger.info('"%s" gave up.', uid) | |
338 | + # logger.info('"%s" gave up', uid) | |
385 | 339 | # return test |
386 | 340 | |
387 | 341 | # ------------------------------------------------------------------------ |
388 | - def event_test(self, uid, cmd, value): | |
342 | + def register_event(self, uid, cmd, value): | |
389 | 343 | '''handles browser events the occur during the test''' |
390 | 344 | if cmd == 'focus': |
391 | 345 | if value: |
... | ... | @@ -395,202 +349,200 @@ class App(): |
395 | 349 | elif cmd == 'size': |
396 | 350 | self._set_screen_area(uid, value) |
397 | 351 | |
398 | - # ------------------------------------------------------------------------ | |
399 | - # --- GETTERS | |
400 | - # ------------------------------------------------------------------------ | |
352 | + # ======================================================================== | |
353 | + # GETTERS | |
354 | + # ======================================================================== | |
355 | + def get_test(self, uid: str) -> Optional[dict]: | |
356 | + '''return student test''' | |
357 | + return self._students[uid]['test'] | |
401 | 358 | |
402 | - # def get_student_name(self, uid): | |
403 | - # return self.online[uid]['student']['name'] | |
359 | + # ------------------------------------------------------------------------ | |
360 | + def get_name(self, uid: str) -> str: | |
361 | + '''return name of student''' | |
362 | + return self._students[uid]['name'] | |
404 | 363 | |
405 | - def get_questions_csv(self): | |
406 | - '''generates a CSV with the grades of the test''' | |
407 | - test_ref = self.testfactory['ref'] | |
408 | - with self._db_session() as sess: | |
409 | - questions = sess.query(Test.id, Test.student_id, Test.starttime, | |
410 | - Question.number, Question.grade)\ | |
411 | - .join(Question)\ | |
412 | - .filter(Test.ref == test_ref)\ | |
413 | - .all() | |
414 | - | |
415 | - qnums = set() # keeps track of all the questions in the test | |
416 | - tests = {} # {test_id: {student_id, starttime, 0: grade, ...}} | |
417 | - for question in questions: | |
418 | - test_id, student_id, starttime, num, grade = question | |
419 | - default_test_id = {'Aluno': student_id, 'Início': starttime} | |
420 | - tests.setdefault(test_id, default_test_id)[num] = grade | |
421 | - qnums.add(num) | |
364 | + # ------------------------------------------------------------------------ | |
365 | + def get_test_config(self) -> dict: | |
366 | + '''return brief test configuration to use as header in /admin''' | |
367 | + return {'title': self._testfactory['title'], | |
368 | + 'ref': self._testfactory['ref'], | |
369 | + 'filename': self._testfactory['testfile'], | |
370 | + 'database': self._testfactory['database'], | |
371 | + 'answers_dir': self._testfactory['answers_dir'] | |
372 | + } | |
422 | 373 | |
374 | + # ------------------------------------------------------------------------ | |
375 | + def get_test_csv(self): | |
376 | + '''generates a CSV with the grades of the test currently running''' | |
377 | + test_ref = self._testfactory['ref'] | |
378 | + with Session(self._engine, future=True) as session: | |
379 | + query = select(Test.student_id, Test.grade, | |
380 | + Test.starttime, Test.finishtime)\ | |
381 | + .where(Test.ref == test_ref)\ | |
382 | + .order_by(Test.student_id) | |
383 | + tests = session.execute(query).all() | |
423 | 384 | if not tests: |
424 | 385 | logger.warning('Empty CSV: there are no tests!') |
425 | 386 | return test_ref, '' |
426 | 387 | |
427 | - cols = ['Aluno', 'Início'] + list(qnums) | |
428 | - | |
429 | 388 | csvstr = io.StringIO() |
430 | - writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, | |
431 | - delimiter=';', quoting=csv.QUOTE_ALL) | |
432 | - writer.writeheader() | |
433 | - writer.writerows(tests.values()) | |
389 | + writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) | |
390 | + writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) | |
391 | + writer.writerows(tests) | |
434 | 392 | return test_ref, csvstr.getvalue() |
435 | 393 | |
436 | - def get_test_csv(self): | |
437 | - '''generates a CSV with the grades of the test currently running''' | |
438 | - test_ref = self.testfactory['ref'] | |
439 | - with self._db_session() as sess: | |
440 | - tests = sess.query(Test.student_id, | |
441 | - Test.grade, | |
442 | - Test.starttime, Test.finishtime)\ | |
443 | - .filter(Test.ref == test_ref)\ | |
444 | - .order_by(Test.student_id)\ | |
445 | - .all() | |
394 | + # ------------------------------------------------------------------------ | |
395 | + def get_detailed_grades_csv(self): | |
396 | + '''generates a CSV with the grades of the test''' | |
397 | + test_ref = self._testfactory['ref'] | |
398 | + with Session(self._engine, future=True) as session: | |
399 | + query = select(Test.id, Test.student_id, Test.starttime, | |
400 | + Question.number, Question.grade)\ | |
401 | + .join(Question)\ | |
402 | + .where(Test.ref == test_ref) | |
403 | + questions = session.execute(query).all() | |
404 | + | |
405 | + cols = ['Aluno', 'Início'] | |
406 | + tests = {} # {test_id: {student_id, starttime, 0: grade, ...}} | |
407 | + for test_id, student_id, starttime, num, grade in questions: | |
408 | + default_test_id = {'Aluno': student_id, 'Início': starttime} | |
409 | + tests.setdefault(test_id, default_test_id)[num] = grade | |
410 | + if num not in cols: | |
411 | + cols.append(num) | |
446 | 412 | |
447 | 413 | if not tests: |
448 | 414 | logger.warning('Empty CSV: there are no tests!') |
449 | 415 | return test_ref, '' |
450 | 416 | |
451 | 417 | csvstr = io.StringIO() |
452 | - writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) | |
453 | - writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) | |
454 | - writer.writerows(tests) | |
455 | - | |
418 | + writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, | |
419 | + delimiter=';', quoting=csv.QUOTE_ALL) | |
420 | + writer.writeheader() | |
421 | + writer.writerows(tests.values()) | |
456 | 422 | return test_ref, csvstr.getvalue() |
457 | 423 | |
458 | 424 | # ------------------------------------------------------------------------ |
459 | - def get_student_grades_from_all_tests(self, uid): | |
460 | - '''get grades of student from all tests''' | |
461 | - with self._db_session() as sess: | |
462 | - return sess.query(Test.title, Test.grade, Test.finishtime)\ | |
463 | - .filter_by(student_id=uid)\ | |
464 | - .order_by(Test.finishtime) | |
465 | - | |
466 | 425 | def get_json_filename_of_test(self, test_id): |
467 | 426 | '''get JSON filename from database given the test_id''' |
468 | - with self._db_session() as sess: | |
469 | - return sess.query(Test.filename)\ | |
470 | - .filter_by(id=test_id)\ | |
471 | - .scalar() | |
427 | + with Session(self._engine, future=True) as session: | |
428 | + query = select(Test.filename).where(Test.id == test_id) | |
429 | + return session.execute(query).scalar() | |
472 | 430 | |
473 | - def get_student_grades_from_test(self, uid, testid): | |
431 | + # ------------------------------------------------------------------------ | |
432 | + def get_grades(self, uid, ref): | |
474 | 433 | '''get grades of student for a given testid''' |
475 | - with self._db_session() as sess: | |
476 | - return sess.query(Test.grade, Test.finishtime, Test.id)\ | |
477 | - .filter_by(student_id=uid)\ | |
478 | - .filter_by(ref=testid)\ | |
479 | - .all() | |
480 | - | |
481 | - def get_students_state(self): | |
482 | - '''get list of states of every student''' | |
483 | - return [{ | |
484 | - 'uid': uid, | |
485 | - 'name': name, | |
486 | - 'allowed': uid in self.allowed, | |
487 | - 'online': uid in self.online, | |
488 | - 'start_time': self.online.get(uid, {}).get('test', {}) | |
489 | - .get('start_time', ''), | |
490 | - 'password_defined': pw != '', | |
491 | - 'unfocus': uid in self.unfocus, | |
492 | - 'area': self.area.get(uid, None), | |
493 | - 'grades': self.get_student_grades_from_test( | |
494 | - uid, self.testfactory['ref']) | |
495 | - } for uid, name, pw in self._get_all_students()] | |
496 | - | |
497 | - # --- private methods ---------------------------------------------------- | |
498 | - def _get_all_students(self): | |
499 | - '''get all students from database''' | |
500 | - with self._db_session() as sess: | |
501 | - return sess.query(Student.id, Student.name, Student.password)\ | |
502 | - .filter(Student.id != '0')\ | |
503 | - .order_by(Student.id) | |
504 | - | |
505 | - # def get_allowed_students(self): | |
506 | - # # set of 'uid' allowed to login | |
507 | - # return self.allowed | |
508 | - | |
509 | - # def get_file(self, uid, ref, key): | |
510 | - # # get filename of (uid, ref, name) if declared in the question | |
511 | - # t = self.get_student_test(uid) | |
512 | - # for q in t['questions']: | |
513 | - # if q['ref'] == ref and key in q['files']: | |
514 | - # return path.abspath(path.join(q['path'], q['files'][key])) | |
434 | + with Session(self._engine, future=True) as session: | |
435 | + query = select(Test.grade, Test.finishtime, Test.id)\ | |
436 | + .where(Test.student_id == uid)\ | |
437 | + .where(Test.ref == ref) | |
438 | + grades = session.execute(query).all() | |
439 | + return [tuple(grade) for grade in grades] | |
515 | 440 | |
516 | 441 | # ------------------------------------------------------------------------ |
517 | - # --- SETTERS | |
518 | - # ------------------------------------------------------------------------ | |
519 | - | |
520 | - def allow_student(self, uid): | |
442 | + def get_students_state(self) -> list: | |
443 | + '''get list of states of every student to show in /admin page''' | |
444 | + return [{ 'uid': uid, | |
445 | + 'name': student['name'], | |
446 | + 'allowed': student['state'] == 'allowed', | |
447 | + 'online': student['state'] == 'online', | |
448 | + 'start_time': student.get('test', {}).get('start_time', ''), | |
449 | + 'unfocus': student.get('unfocus', False), | |
450 | + 'area': student.get('area', 1.0), | |
451 | + 'grades': self.get_grades(uid, self._testfactory['ref']) } | |
452 | + for uid, student in self._students.items()] | |
453 | + | |
454 | + # ======================================================================== | |
455 | + # SETTERS | |
456 | + # ======================================================================== | |
457 | + def allow_student(self, uid: str) -> None: | |
521 | 458 | '''allow a single student to login''' |
522 | - self.allowed.add(uid) | |
523 | - logger.info('"%s" allowed to login.', uid) | |
459 | + self._students[uid]['state'] = 'allowed' | |
460 | + logger.info('"%s" allowed to login', uid) | |
524 | 461 | |
525 | - def deny_student(self, uid): | |
462 | + # ------------------------------------------------------------------------ | |
463 | + def deny_student(self, uid: str) -> None: | |
526 | 464 | '''deny a single student to login''' |
527 | - self.allowed.discard(uid) | |
465 | + student = self._students[uid] | |
466 | + if student['state'] == 'allowed': | |
467 | + student['state'] = 'offline' | |
528 | 468 | logger.info('"%s" denied to login', uid) |
529 | 469 | |
530 | - def allow_all_students(self): | |
470 | + # ------------------------------------------------------------------------ | |
471 | + def allow_all_students(self) -> None: | |
531 | 472 | '''allow all students to login''' |
532 | - all_students = self._get_all_students() | |
533 | - self.allowed.update(s[0] for s in all_students) | |
534 | - logger.info('Allowed all %d students.', len(self.allowed)) | |
473 | + for student in self._students.values(): | |
474 | + student['state'] = 'allowed' | |
475 | + logger.info('Allowed %d students', len(self._students)) | |
535 | 476 | |
536 | - def deny_all_students(self): | |
477 | + # ------------------------------------------------------------------------ | |
478 | + def deny_all_students(self) -> None: | |
537 | 479 | '''deny all students to login''' |
538 | 480 | logger.info('Denying all students...') |
539 | - self.allowed.clear() | |
481 | + for student in self._students.values(): | |
482 | + if student['state'] == 'allowed': | |
483 | + student['state'] = 'offline' | |
484 | + | |
485 | + # ------------------------------------------------------------------------ | |
486 | + async def insert_new_student(self, uid: str, name: str) -> None: | |
487 | + '''insert new student into the database''' | |
488 | + with Session(self._engine, future=True) as session: | |
489 | + try: | |
490 | + session.add(Student(id=uid, name=name, password='')) | |
491 | + session.commit() | |
492 | + except IntegrityError: | |
493 | + logger.warning('"%s" already exists!', uid) | |
494 | + session.rollback() | |
495 | + return | |
496 | + logger.info('New student added: %s %s', uid, name) | |
497 | + self._students[uid] = { | |
498 | + 'name': name, | |
499 | + 'state': 'offline', | |
500 | + 'test': await self._testfactory.generate(), | |
501 | + } | |
540 | 502 | |
541 | - def allow_list(self, filename): | |
542 | - '''allow students listed in file (one number per line)''' | |
503 | + # ------------------------------------------------------------------------ | |
504 | + def allow_from_list(self, filename: str) -> None: | |
505 | + '''allow students listed in text file (one number per line)''' | |
506 | + # parse list of students to allow (one number per line) | |
543 | 507 | try: |
544 | - with open(filename, 'r') as file: | |
545 | - allowed_in_file = {s.strip() for s in file} - {''} | |
546 | - except Exception as exc: | |
508 | + with open(filename, 'r', encoding='utf-8') as file: | |
509 | + allowed = {line.strip() for line in file} | |
510 | + allowed.discard('') | |
511 | + except OSError as exc: | |
547 | 512 | error_msg = f'Cannot read file {filename}' |
548 | 513 | logger.critical(error_msg) |
549 | 514 | raise AppException(error_msg) from exc |
550 | 515 | |
551 | - enrolled = set(s[0] for s in self._get_all_students()) # in database | |
552 | - self.allowed.update(allowed_in_file & enrolled) | |
553 | - logger.info('Allowed %d students provided in "%s"', len(self.allowed), | |
554 | - filename) | |
516 | + # update allowed state (missing are students allowed that don't exist) | |
517 | + missing = 0 | |
518 | + for uid in allowed: | |
519 | + try: | |
520 | + self.allow_student(uid) | |
521 | + except KeyError: | |
522 | + logger.warning('Allowed student "%s" does not exist!', uid) | |
523 | + missing += 1 | |
555 | 524 | |
556 | - not_enrolled = allowed_in_file - enrolled | |
557 | - if not_enrolled: | |
558 | - logger.warning(' but found students not in the database: %s', | |
559 | - ', '.join(not_enrolled)) | |
525 | + logger.info('Allowed %d students', len(allowed)-missing) | |
526 | + if missing: | |
527 | + logger.warning(' %d missing!', missing) | |
560 | 528 | |
529 | + # ------------------------------------------------------------------------ | |
561 | 530 | def _focus_student(self, uid): |
562 | 531 | '''set student in focus state''' |
563 | - self.unfocus.discard(uid) | |
532 | + self._students[uid]['unfocus'] = False | |
564 | 533 | logger.info('"%s" focus', uid) |
565 | 534 | |
535 | + # ------------------------------------------------------------------------ | |
566 | 536 | def _unfocus_student(self, uid): |
567 | 537 | '''set student in unfocus state''' |
568 | - self.unfocus.add(uid) | |
538 | + self._students[uid]['unfocus'] = True | |
569 | 539 | logger.info('"%s" unfocus', uid) |
570 | 540 | |
541 | + # ------------------------------------------------------------------------ | |
571 | 542 | def _set_screen_area(self, uid, sizes): |
572 | 543 | '''set current browser area as detected in resize event''' |
573 | 544 | scr_y, scr_x, win_y, win_x = sizes |
574 | 545 | area = win_x * win_y / (scr_x * scr_y) * 100 |
575 | - self.area[uid] = area | |
546 | + self._students[uid]['area'] = area | |
576 | 547 | logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', |
577 | 548 | uid, area, win_x, win_y, scr_x, scr_y) |
578 | - | |
579 | - async def update_student_password(self, uid, password=''): | |
580 | - '''change password on the database''' | |
581 | - if password: | |
582 | - password = await hash_password(password) | |
583 | - with self._db_session() as sess: | |
584 | - student = sess.query(Student).filter_by(id=uid).one() | |
585 | - student.password = password | |
586 | - logger.info('"%s" password updated.', uid) | |
587 | - | |
588 | - def insert_new_student(self, uid, name): | |
589 | - '''insert new student into the database''' | |
590 | - try: | |
591 | - with self._db_session() as sess: | |
592 | - sess.add(Student(id=uid, name=name, password='')) | |
593 | - except exc.SQLAlchemyError: | |
594 | - logger.error('Insert failed: student %s already exists?', uid) | |
595 | - else: | |
596 | - logger.info('New student: "%s", "%s"', uid, name) | ... | ... |
perguntations/initdb.py
... | ... | @@ -13,17 +13,18 @@ from concurrent.futures import ThreadPoolExecutor |
13 | 13 | |
14 | 14 | # installed packages |
15 | 15 | import bcrypt |
16 | -import sqlalchemy as sa | |
17 | -import sqlalchemy.orm | |
16 | +from sqlalchemy import create_engine, select | |
17 | +from sqlalchemy.orm import Session | |
18 | 18 | from sqlalchemy.exc import IntegrityError |
19 | 19 | |
20 | 20 | # this project |
21 | -from perguntations.models import Base, Student | |
21 | +from .models import Base, Student | |
22 | 22 | |
23 | 23 | |
24 | 24 | # ============================================================================ |
25 | 25 | def parse_commandline_arguments(): |
26 | 26 | '''Parse command line options''' |
27 | + | |
27 | 28 | parser = argparse.ArgumentParser( |
28 | 29 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
29 | 30 | description='Insert new users into a database. Users can be imported ' |
... | ... | @@ -104,7 +105,7 @@ def get_students_from_csv(filename): |
104 | 105 | |
105 | 106 | |
106 | 107 | # ============================================================================ |
107 | -def hashpw(student, password=None): | |
108 | +def hashpw(student, password=None) -> None: | |
108 | 109 | '''replace password by hash for a single student''' |
109 | 110 | print('.', end='', flush=True) |
110 | 111 | if password is None: |
... | ... | @@ -115,12 +116,13 @@ def hashpw(student, password=None): |
115 | 116 | |
116 | 117 | |
117 | 118 | # ============================================================================ |
118 | -def insert_students_into_db(session, students): | |
119 | +def insert_students_into_db(session, students) -> None: | |
119 | 120 | '''insert list of students into the database''' |
120 | 121 | try: |
121 | 122 | session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) |
122 | 123 | for s in students]) |
123 | 124 | session.commit() |
125 | + | |
124 | 126 | except IntegrityError: |
125 | 127 | print('!!! Integrity error. Users already in database. Aborted !!!\n') |
126 | 128 | session.rollback() |
... | ... | @@ -129,11 +131,12 @@ def insert_students_into_db(session, students): |
129 | 131 | # ============================================================================ |
130 | 132 | def show_students_in_database(session, verbose=False): |
131 | 133 | '''get students from database''' |
132 | - users = session.query(Student).all() | |
134 | + users = session.execute(select(Student)).scalars().all() | |
135 | + # users = session.query(Student).all() | |
136 | + total = len(users) | |
133 | 137 | |
134 | - total_users = len(users) | |
135 | 138 | print('Registered users:') |
136 | - if total_users == 0: | |
139 | + if total == 0: | |
137 | 140 | print(' -- none --') |
138 | 141 | else: |
139 | 142 | users.sort(key=lambda u: f'{u.id:>12}') # sort by number |
... | ... | @@ -142,13 +145,13 @@ def show_students_in_database(session, verbose=False): |
142 | 145 | print(f'{user.id:>12} {user.name}') |
143 | 146 | else: |
144 | 147 | print(f'{users[0].id:>12} {users[0].name}') |
145 | - if total_users > 1: | |
148 | + if total > 1: | |
146 | 149 | print(f'{users[1].id:>12} {users[1].name}') |
147 | - if total_users > 3: | |
150 | + if total > 3: | |
148 | 151 | print(' | |') |
149 | - if total_users > 2: | |
152 | + if total > 2: | |
150 | 153 | print(f'{users[-1].id:>12} {users[-1].name}') |
151 | - print(f'Total: {total_users}.') | |
154 | + print(f'Total: {total}.') | |
152 | 155 | |
153 | 156 | |
154 | 157 | # ============================================================================ |
... | ... | @@ -158,39 +161,45 @@ def main(): |
158 | 161 | args = parse_commandline_arguments() |
159 | 162 | |
160 | 163 | # --- database |
161 | - print(f'Using database: {args.db}') | |
162 | - engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) | |
164 | + print(f'Database: {args.db}') | |
165 | + engine = create_engine(f'sqlite:///{args.db}', echo=False, future=True) | |
163 | 166 | Base.metadata.create_all(engine) # Criates schema if needed |
164 | - SessionMaker = sqlalchemy.orm.sessionmaker(bind=engine) | |
165 | - session = SessionMaker() | |
167 | + session = Session(engine, future=True) | |
166 | 168 | |
167 | 169 | # --- make list of students to insert |
168 | - new_students = [] | |
170 | + students = [] | |
169 | 171 | |
170 | 172 | if args.admin: |
171 | 173 | print('Adding user: 0, Admin.') |
172 | - new_students.append({'uid': '0', 'name': 'Admin'}) | |
174 | + students.append({'uid': '0', 'name': 'Admin'}) | |
173 | 175 | |
174 | 176 | for csvfile in args.csvfile: |
175 | 177 | print('Adding users from:', csvfile) |
176 | - new_students.extend(get_students_from_csv(csvfile)) | |
178 | + students.extend(get_students_from_csv(csvfile)) | |
179 | + | |
180 | + for csvfile in args.csvfile: | |
181 | + print('Adding users from:', csvfile) | |
182 | + students.extend(get_students_from_csv(csvfile)) | |
177 | 183 | |
178 | 184 | if args.add: |
179 | 185 | for uid, name in args.add: |
180 | 186 | print(f'Adding user: {uid}, {name}.') |
181 | - new_students.append({'uid': uid, 'name': name}) | |
187 | + students.append({'uid': uid, 'name': name}) | |
182 | 188 | |
183 | 189 | # --- insert new students |
184 | - if new_students: | |
190 | + if students: | |
185 | 191 | print('Generating password hashes', end='') |
186 | - with ThreadPoolExecutor() as executor: # hashing in parallel | |
187 | - executor.map(lambda s: hashpw(s, args.pw), new_students) | |
188 | - print(f'\nInserting {len(new_students)}') | |
189 | - insert_students_into_db(session, new_students) | |
192 | + with ThreadPoolExecutor() as executor: # hashing | |
193 | + executor.map(lambda s: hashpw(s, args.pw), students) | |
194 | + print(f'\nInserting {len(students)}') | |
195 | + insert_students_into_db(session, students) | |
190 | 196 | |
191 | 197 | # --- update all students |
192 | 198 | if args.update_all: |
193 | - all_students = session.query(Student).filter(Student.id != '0').all() | |
199 | + all_students = session.execute( | |
200 | + select(Student).where(Student.id != '0') | |
201 | + ).scalars().all() | |
202 | + | |
194 | 203 | print(f'Updating password of {len(all_students)} users', end='') |
195 | 204 | for student in all_students: |
196 | 205 | password = (args.pw or student.id).encode('utf-8') |
... | ... | @@ -203,9 +212,12 @@ def main(): |
203 | 212 | else: |
204 | 213 | for student_id in args.update: |
205 | 214 | print(f'Updating password of {student_id}') |
206 | - student = session.query(Student).get(student_id) | |
207 | - password = (args.pw or student_id).encode('utf-8') | |
208 | - student.password = bcrypt.hashpw(password, bcrypt.gensalt()) | |
215 | + student = session.execute( | |
216 | + select(Student). | |
217 | + where(Student.id == student_id) | |
218 | + ).scalar_one() | |
219 | + new_password = (args.pw or student_id).encode('utf-8') | |
220 | + student.password = bcrypt.hashpw(new_password, bcrypt.gensalt()) | |
209 | 221 | session.commit() |
210 | 222 | |
211 | 223 | show_students_in_database(session, args.verbose) | ... | ... |
perguntations/main.py
1 | 1 | #!/usr/bin/env python3 |
2 | 2 | |
3 | 3 | ''' |
4 | -Main file that starts the application and the web server | |
4 | +Start application and web server | |
5 | 5 | ''' |
6 | 6 | |
7 | 7 | |
... | ... | @@ -10,20 +10,17 @@ import argparse |
10 | 10 | import logging |
11 | 11 | import logging.config |
12 | 12 | import os |
13 | -from os import environ, path | |
14 | 13 | import ssl |
15 | 14 | import sys |
16 | -# from typing import Any, Dict | |
17 | 15 | |
18 | 16 | # this project |
19 | -from perguntations.app import App, AppException | |
20 | -from perguntations.serve import run_webserver | |
21 | -from perguntations.tools import load_yaml | |
22 | -from perguntations import APP_NAME, APP_VERSION | |
23 | - | |
17 | +from .app import App, AppException | |
18 | +from .serve import run_webserver | |
19 | +from .tools import load_yaml | |
20 | +from . import APP_NAME, APP_VERSION | |
24 | 21 | |
25 | 22 | # ---------------------------------------------------------------------------- |
26 | -def parse_cmdline_arguments(): | |
23 | +def parse_cmdline_arguments() -> argparse.Namespace: | |
27 | 24 | ''' |
28 | 25 | Get command line arguments |
29 | 26 | ''' |
... | ... | @@ -33,7 +30,6 @@ def parse_cmdline_arguments(): |
33 | 30 | 'included with this software before running the server.') |
34 | 31 | parser.add_argument('testfile', |
35 | 32 | type=str, |
36 | - # nargs='+', # TODO | |
37 | 33 | help='tests in YAML format') |
38 | 34 | parser.add_argument('--allow-all', |
39 | 35 | action='store_true', |
... | ... | @@ -44,9 +40,6 @@ def parse_cmdline_arguments(): |
44 | 40 | parser.add_argument('--debug', |
45 | 41 | action='store_true', |
46 | 42 | help='Enable debug messages') |
47 | - parser.add_argument('--show-ref', | |
48 | - action='store_true', | |
49 | - help='Show question references') | |
50 | 43 | parser.add_argument('--review', |
51 | 44 | action='store_true', |
52 | 45 | help='Review mode: doesn\'t generate test') |
... | ... | @@ -63,56 +56,50 @@ def parse_cmdline_arguments(): |
63 | 56 | help='Show version information and exit') |
64 | 57 | return parser.parse_args() |
65 | 58 | |
66 | - | |
67 | 59 | # ---------------------------------------------------------------------------- |
68 | 60 | def get_logger_config(debug=False) -> dict: |
69 | 61 | ''' |
70 | 62 | Load logger configuration from ~/.config directory if exists, |
71 | 63 | otherwise set default paramenters. |
72 | 64 | ''' |
65 | + | |
66 | + file = 'logger-debug.yaml' if debug else 'logger.yaml' | |
67 | + path = os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config/')) | |
68 | + try: | |
69 | + return load_yaml(os.path.join(path, APP_NAME, file)) | |
70 | + except OSError: | |
71 | + print('Using default logger configuration...') | |
72 | + | |
73 | 73 | if debug: |
74 | - filename = 'logger-debug.yaml' | |
75 | 74 | level = 'DEBUG' |
75 | + fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s' | |
76 | + dateformat = '' | |
76 | 77 | else: |
77 | - filename = 'logger.yaml' | |
78 | 78 | level = 'INFO' |
79 | - | |
80 | - config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/') | |
81 | - config_file = path.join(path.expanduser(config_dir), APP_NAME, filename) | |
82 | - | |
83 | - default_config = { | |
84 | - 'version': 1, | |
85 | - 'formatters': { | |
86 | - 'standard': { | |
87 | - 'format': '%(asctime)s %(levelname)-8s %(message)s', | |
88 | - 'datefmt': '%H:%M:%S', | |
89 | - }, | |
90 | - }, | |
91 | - 'handlers': { | |
92 | - 'default': { | |
93 | - 'level': level, | |
94 | - 'class': 'logging.StreamHandler', | |
95 | - 'formatter': 'standard', | |
96 | - 'stream': 'ext://sys.stdout', | |
79 | + fmt = '%(asctime)s| %(levelname)-8s| %(message)s' | |
80 | + dateformat = '%Y-%m-%d %H:%M:%S' | |
81 | + modules = ['main', 'serve', 'app', 'models', 'questions', 'test', | |
82 | + 'testfactory', 'tools'] | |
83 | + logger = {'handlers': ['default'], 'level': level, 'propagate': False} | |
84 | + return { | |
85 | + 'version': 1, | |
86 | + 'formatters': { | |
87 | + 'standard': { | |
88 | + 'format': fmt, | |
89 | + 'datefmt': dateformat, | |
90 | + }, | |
97 | 91 | }, |
98 | - }, | |
99 | - 'loggers': { | |
100 | - '': { # configuration for serve.py | |
101 | - 'handlers': ['default'], | |
102 | - 'level': level, | |
92 | + 'handlers': { | |
93 | + 'default': { | |
94 | + 'level': level, | |
95 | + 'class': 'logging.StreamHandler', | |
96 | + 'formatter': 'standard', | |
97 | + 'stream': 'ext://sys.stdout', | |
98 | + }, | |
103 | 99 | }, |
104 | - }, | |
100 | + 'loggers': {f'{APP_NAME}.{module}': logger for module in modules} | |
105 | 101 | } |
106 | 102 | |
107 | - modules = ['app', 'models', 'questions', 'test', 'testfactory', 'tools'] | |
108 | - logger = {'handlers': ['default'], 'level': level, 'propagate': False} | |
109 | - | |
110 | - default_config['loggers'].update({f'{APP_NAME}.{module}': logger | |
111 | - for module in modules}) | |
112 | - | |
113 | - return load_yaml(config_file, default=default_config) | |
114 | - | |
115 | - | |
116 | 103 | # ---------------------------------------------------------------------------- |
117 | 104 | def main() -> None: |
118 | 105 | ''' |
... | ... | @@ -122,15 +109,16 @@ def main() -> None: |
122 | 109 | |
123 | 110 | # --- Setup logging ------------------------------------------------------ |
124 | 111 | logging.config.dictConfig(get_logger_config(args.debug)) |
125 | - logging.info('====================== Start Logging ======================') | |
112 | + logger = logging.getLogger(__name__) | |
113 | + | |
114 | + logger.info('================== Start Logging ==================') | |
126 | 115 | |
127 | 116 | # --- start application -------------------------------------------------- |
128 | 117 | config = { |
129 | 118 | 'testfile': args.testfile, |
130 | - 'debug': args.debug, | |
131 | 119 | 'allow_all': args.allow_all, |
132 | 120 | 'allow_list': args.allow_list, |
133 | - 'show_ref': args.show_ref, | |
121 | + 'debug': args.debug, | |
134 | 122 | 'review': args.review, |
135 | 123 | 'correct': args.correct, |
136 | 124 | } |
... | ... | @@ -138,24 +126,24 @@ def main() -> None: |
138 | 126 | try: |
139 | 127 | app = App(config) |
140 | 128 | except AppException: |
141 | - logging.critical('Failed to start application.') | |
142 | - sys.exit(-1) | |
129 | + logger.critical('Failed to start application!') | |
130 | + sys.exit(1) | |
143 | 131 | |
144 | 132 | # --- get SSL certificates ----------------------------------------------- |
145 | 133 | if 'XDG_DATA_HOME' in os.environ: |
146 | - certs_dir = path.join(os.environ['XDG_DATA_HOME'], 'certs') | |
134 | + certs_dir = os.path.join(os.environ['XDG_DATA_HOME'], 'certs') | |
147 | 135 | else: |
148 | - certs_dir = path.expanduser('~/.local/share/certs') | |
136 | + certs_dir = os.path.expanduser('~/.local/share/certs') | |
149 | 137 | |
150 | 138 | ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) |
151 | 139 | try: |
152 | - ssl_opt.load_cert_chain(path.join(certs_dir, 'cert.pem'), | |
153 | - path.join(certs_dir, 'privkey.pem')) | |
140 | + ssl_opt.load_cert_chain(os.path.join(certs_dir, 'cert.pem'), | |
141 | + os.path.join(certs_dir, 'privkey.pem')) | |
154 | 142 | except FileNotFoundError: |
155 | - logging.critical('SSL certificates missing in %s', certs_dir) | |
156 | - sys.exit(-1) | |
143 | + logger.critical('SSL certificates missing in %s', certs_dir) | |
144 | + sys.exit(1) | |
157 | 145 | |
158 | - # --- run webserver ---------------------------------------------------- | |
146 | + # --- run webserver ------------------------------------------------------ | |
159 | 147 | run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug) |
160 | 148 | |
161 | 149 | ... | ... |
perguntations/models.py
1 | 1 | ''' |
2 | +perguntations/models.py | |
2 | 3 | SQLAlchemy ORM |
3 | - | |
4 | -The classes below correspond to database tables | |
5 | 4 | ''' |
6 | 5 | |
6 | +from typing import Any | |
7 | 7 | |
8 | 8 | from sqlalchemy import Column, ForeignKey, Integer, Float, String |
9 | -from sqlalchemy.ext.declarative import declarative_base | |
10 | -from sqlalchemy.orm import relationship | |
9 | +from sqlalchemy.orm import declarative_base, relationship | |
11 | 10 | |
12 | 11 | |
13 | -# ============================================================================ | |
14 | -# Declare ORM | |
15 | -Base = declarative_base() | |
12 | +# FIXME Any is a workaround for static type checking | |
13 | +# (https://github.com/python/mypy/issues/6372) | |
14 | +Base: Any = declarative_base() | |
16 | 15 | |
17 | 16 | |
18 | 17 | # ---------------------------------------------------------------------------- |
... | ... | @@ -26,11 +25,11 @@ class Student(Base): |
26 | 25 | # --- |
27 | 26 | tests = relationship('Test', back_populates='student') |
28 | 27 | |
29 | - def __str__(self): | |
30 | - return (f'Student:\n' | |
31 | - f' id: "{self.id}"\n' | |
32 | - f' name: "{self.name}"\n' | |
33 | - f' password: "{self.password}"\n') | |
28 | + def __repr__(self): | |
29 | + return (f'Student(' | |
30 | + f'id={self.id!r}, ' | |
31 | + f'name={self.name!r}, ' | |
32 | + f'password={self.password!r})') | |
34 | 33 | |
35 | 34 | |
36 | 35 | # ---------------------------------------------------------------------------- |
... | ... | @@ -52,18 +51,18 @@ class Test(Base): |
52 | 51 | student = relationship('Student', back_populates='tests') |
53 | 52 | questions = relationship('Question', back_populates='test') |
54 | 53 | |
55 | - def __str__(self): | |
56 | - return (f'Test:\n' | |
57 | - f' id: {self.id}\n' | |
58 | - f' ref: "{self.ref}"\n' | |
59 | - f' title: "{self.title}"\n' | |
60 | - f' grade: {self.grade}\n' | |
61 | - f' state: "{self.state}"\n' | |
62 | - f' comment: "{self.comment}"\n' | |
63 | - f' starttime: "{self.starttime}"\n' | |
64 | - f' finishtime: "{self.finishtime}"\n' | |
65 | - f' filename: "{self.filename}"\n' | |
66 | - f' student_id: "{self.student_id}"\n') | |
54 | + def __repr__(self): | |
55 | + return (f'Test(' | |
56 | + f'id={self.id!r}, ' | |
57 | + f'ref={self.ref!r}, ' | |
58 | + f'title={self.title!r}, ' | |
59 | + f'grade={self.grade!r}, ' | |
60 | + f'state={self.state!r}, ' | |
61 | + f'comment={self.comment!r}, ' | |
62 | + f'starttime={self.starttime!r}, ' | |
63 | + f'finishtime={self.finishtime!r}, ' | |
64 | + f'filename={self.filename!r}, ' | |
65 | + f'student_id={self.student_id!r})') | |
67 | 66 | |
68 | 67 | |
69 | 68 | # --------------------------------------------------------------------------- |
... | ... | @@ -82,13 +81,13 @@ class Question(Base): |
82 | 81 | # --- |
83 | 82 | test = relationship('Test', back_populates='questions') |
84 | 83 | |
85 | - def __str__(self): | |
86 | - return (f'Question:\n' | |
87 | - f' id: {self.id}\n' | |
88 | - f' number: {self.number}\n' | |
89 | - f' ref: "{self.ref}"\n' | |
90 | - f' grade: {self.grade}\n' | |
91 | - f' comment: "{self.comment}"\n' | |
92 | - f' starttime: "{self.starttime}"\n' | |
93 | - f' finishtime: "{self.finishtime}"\n' | |
94 | - f' test_id: "{self.test_id}"\n') | |
84 | + def __repr__(self): | |
85 | + return (f'Question(' | |
86 | + f'id={self.id!r}, ' | |
87 | + f'number={self.number!r}, ' | |
88 | + f'ref={self.ref!r}, ' | |
89 | + f'grade={self.grade!r}, ' | |
90 | + f'comment={self.comment!r}, ' | |
91 | + f'starttime={self.starttime!r}, ' | |
92 | + f'finishtime={self.finishtime!r}, ' | |
93 | + f'test_id={self.test_id!r})') | ... | ... |
perguntations/parser_markdown.py
... | ... | @@ -137,9 +137,9 @@ class HighlightRenderer(mistune.Renderer): |
137 | 137 | return '<table class="table table-sm"><thead class="thead-light">' \ |
138 | 138 | + header + '</thead><tbody>' + body + '</tbody></table>' |
139 | 139 | |
140 | - def image(self, src, title, alt): | |
140 | + def image(self, src, title, text): | |
141 | 141 | '''render image''' |
142 | - alt = mistune.escape(alt, quote=True) | |
142 | + alt = mistune.escape(text, quote=True) | |
143 | 143 | if title is not None: |
144 | 144 | if title: # not empty string, show as caption |
145 | 145 | title = mistune.escape(title, quote=True) | ... | ... |
perguntations/questions.py
1 | 1 | ''' |
2 | -Classes the implement several types of questions. | |
2 | +File: perguntations/questions.py | |
3 | +Description: Classes the implement several types of questions. | |
3 | 4 | ''' |
4 | 5 | |
5 | 6 | |
6 | 7 | # python standard library |
7 | -import asyncio | |
8 | 8 | from datetime import datetime |
9 | 9 | import logging |
10 | -from os import path | |
10 | +import os | |
11 | 11 | import random |
12 | 12 | import re |
13 | 13 | from typing import Any, Dict, NewType |
14 | 14 | import uuid |
15 | 15 | |
16 | - | |
17 | -# from urllib.error import HTTPError | |
18 | -# import json | |
19 | -# import http.client | |
20 | - | |
21 | - | |
22 | 16 | # this project |
23 | -from perguntations.tools import run_script, run_script_async | |
17 | +from .tools import run_script, run_script_async | |
24 | 18 | |
25 | 19 | # setup logger for this module |
26 | 20 | logger = logging.getLogger(__name__) |
27 | 21 | |
28 | - | |
29 | 22 | QDict = NewType('QDict', Dict[str, Any]) |
30 | 23 | |
31 | 24 | |
32 | - | |
33 | - | |
34 | 25 | class QuestionException(Exception): |
35 | 26 | '''Exceptions raised in this module''' |
36 | 27 | |
... | ... | @@ -45,8 +36,6 @@ class Question(dict): |
45 | 36 | for each student. |
46 | 37 | Instances can shuffle options or automatically generate questions. |
47 | 38 | ''' |
48 | - # def __init__(self, q: QDict) -> None: | |
49 | - # super().__init__(q) | |
50 | 39 | |
51 | 40 | def gen(self) -> None: |
52 | 41 | ''' |
... | ... | @@ -96,9 +85,6 @@ class QuestionRadio(Question): |
96 | 85 | ''' |
97 | 86 | |
98 | 87 | # ------------------------------------------------------------------------ |
99 | - # def __init__(self, q: QDict) -> None: | |
100 | - # super().__init__(q) | |
101 | - | |
102 | 88 | def gen(self) -> None: |
103 | 89 | ''' |
104 | 90 | Sets defaults, performs checks and generates the actual question |
... | ... | @@ -128,8 +114,7 @@ class QuestionRadio(Question): |
128 | 114 | # e.g. correct: 2 --> correct: [0,0,1,0,0] |
129 | 115 | if isinstance(self['correct'], int): |
130 | 116 | if not 0 <= self['correct'] < nopts: |
131 | - msg = (f'`correct` out of range 0..{nopts-1}. ' | |
132 | - f'In question "{self["ref"]}"') | |
117 | + msg = f'"{self["ref"]}": correct out of range 0..{nopts-1}' | |
133 | 118 | logger.error(msg) |
134 | 119 | raise QuestionException(msg) |
135 | 120 | |
... | ... | @@ -139,8 +124,7 @@ class QuestionRadio(Question): |
139 | 124 | elif isinstance(self['correct'], list): |
140 | 125 | # must match number of options |
141 | 126 | if len(self['correct']) != nopts: |
142 | - msg = (f'{nopts} options vs {len(self["correct"])} correct. ' | |
143 | - f'In question "{self["ref"]}"') | |
127 | + msg = f'"{self["ref"]}": number of options/correct mismatch' | |
144 | 128 | logger.error(msg) |
145 | 129 | raise QuestionException(msg) |
146 | 130 | |
... | ... | @@ -148,23 +132,20 @@ class QuestionRadio(Question): |
148 | 132 | try: |
149 | 133 | self['correct'] = [float(x) for x in self['correct']] |
150 | 134 | except (ValueError, TypeError) as exc: |
151 | - msg = ('`correct` must be list of numbers or booleans.' | |
152 | - f'In "{self["ref"]}"') | |
135 | + msg = f'"{self["ref"]}": correct must contain floats or bools' | |
153 | 136 | logger.error(msg) |
154 | 137 | raise QuestionException(msg) from exc |
155 | 138 | |
156 | 139 | # check grade boundaries |
157 | 140 | if self['discount'] and not all(0.0 <= x <= 1.0 |
158 | 141 | for x in self['correct']): |
159 | - msg = ('`correct` values must be in the interval [0.0, 1.0]. ' | |
160 | - f'In "{self["ref"]}"') | |
142 | + msg = f'"{self["ref"]}": correct must be in [0.0, 1.0]' | |
161 | 143 | logger.error(msg) |
162 | 144 | raise QuestionException(msg) |
163 | 145 | |
164 | 146 | # at least one correct option |
165 | 147 | if all(x < 1.0 for x in self['correct']): |
166 | - msg = ('At least one correct option is required. ' | |
167 | - f'In "{self["ref"]}"') | |
148 | + msg = f'"{self["ref"]}": has no correct options' | |
168 | 149 | logger.error(msg) |
169 | 150 | raise QuestionException(msg) |
170 | 151 | |
... | ... | @@ -231,9 +212,6 @@ class QuestionCheckbox(Question): |
231 | 212 | ''' |
232 | 213 | |
233 | 214 | # ------------------------------------------------------------------------ |
234 | - # def __init__(self, q: QDict) -> None: | |
235 | - # super().__init__(q) | |
236 | - | |
237 | 215 | def gen(self) -> None: |
238 | 216 | super().gen() |
239 | 217 | |
... | ... | @@ -288,19 +266,6 @@ class QuestionCheckbox(Question): |
288 | 266 | f'Please fix "{self["ref"]}" in "{self["path"]}"') |
289 | 267 | logger.error(msg) |
290 | 268 | raise QuestionException(msg) |
291 | - # msg0 = ('+--------------- BEHAVIOR CHANGE NOTICE ---------------+') | |
292 | - # msg1 = ('| Correct values in checkbox questions must be in the |') | |
293 | - # msg2 = ('| interval [0.0, 1.0]. I will convert to the new |') | |
294 | - # msg3 = ('| behavior, for now, but you should fix it. |') | |
295 | - # msg4 = ('+------------------------------------------------------+') | |
296 | - # logger.warning(msg0) | |
297 | - # logger.warning(msg1) | |
298 | - # logger.warning(msg2) | |
299 | - # logger.warning(msg3) | |
300 | - # logger.warning(msg4) | |
301 | - # logger.warning('please fix "%s"', self["ref"]) | |
302 | - # # normalize to [0,1] | |
303 | - # self['correct'] = [(x+1)/2 for x in self['correct']] | |
304 | 269 | |
305 | 270 | # if an option is a list of (right, wrong), pick one |
306 | 271 | options = [] |
... | ... | @@ -356,9 +321,6 @@ class QuestionText(Question): |
356 | 321 | ''' |
357 | 322 | |
358 | 323 | # ------------------------------------------------------------------------ |
359 | - # def __init__(self, q: QDict) -> None: | |
360 | - # super().__init__(q) | |
361 | - | |
362 | 324 | def gen(self) -> None: |
363 | 325 | super().gen() |
364 | 326 | self.set_defaults(QDict({ |
... | ... | @@ -389,12 +351,13 @@ class QuestionText(Question): |
389 | 351 | def transform(self, ans): |
390 | 352 | '''apply optional filters to the answer''' |
391 | 353 | |
354 | + # apply transformations in sequence | |
392 | 355 | for transform in self['transform']: |
393 | 356 | if transform == 'remove_space': # removes all spaces |
394 | 357 | ans = ans.replace(' ', '') |
395 | 358 | elif transform == 'trim': # removes spaces around |
396 | 359 | ans = ans.strip() |
397 | - elif transform == 'normalize_space': # replaces multiple spaces by one | |
360 | + elif transform == 'normalize_space': # replaces many spaces by one | |
398 | 361 | ans = re.sub(r'\s+', ' ', ans.strip()) |
399 | 362 | elif transform == 'lower': # convert to lowercase |
400 | 363 | ans = ans.lower() |
... | ... | @@ -410,7 +373,7 @@ class QuestionText(Question): |
410 | 373 | super().correct() |
411 | 374 | |
412 | 375 | if self['answer'] is not None: |
413 | - answer = self.transform(self['answer']) # apply transformations | |
376 | + answer = self.transform(self['answer']) | |
414 | 377 | self['grade'] = 1.0 if answer in self['correct'] else 0.0 |
415 | 378 | |
416 | 379 | |
... | ... | @@ -427,9 +390,6 @@ class QuestionTextRegex(Question): |
427 | 390 | ''' |
428 | 391 | |
429 | 392 | # ------------------------------------------------------------------------ |
430 | - # def __init__(self, q: QDict) -> None: | |
431 | - # super().__init__(q) | |
432 | - | |
433 | 393 | def gen(self) -> None: |
434 | 394 | super().gen() |
435 | 395 | |
... | ... | @@ -442,14 +402,6 @@ class QuestionTextRegex(Question): |
442 | 402 | if not isinstance(self['correct'], list): |
443 | 403 | self['correct'] = [self['correct']] |
444 | 404 | |
445 | - # converts patterns to compiled versions | |
446 | - # try: | |
447 | - # self['correct'] = [re.compile(a) for a in self['correct']] | |
448 | - # except Exception as exc: | |
449 | - # msg = f'Failed to compile regex in "{self["ref"]}"' | |
450 | - # logger.error(msg) | |
451 | - # raise QuestionException(msg) from exc | |
452 | - | |
453 | 405 | # ------------------------------------------------------------------------ |
454 | 406 | def correct(self) -> None: |
455 | 407 | super().correct() |
... | ... | @@ -464,15 +416,6 @@ class QuestionTextRegex(Question): |
464 | 416 | regex, self['answer']) |
465 | 417 | self['grade'] = 0.0 |
466 | 418 | |
467 | - # try: | |
468 | - # if regex.match(self['answer']): | |
469 | - # self['grade'] = 1.0 | |
470 | - # return | |
471 | - # except TypeError: | |
472 | - # logger.error('While matching regex %s with answer "%s".', | |
473 | - # regex.pattern, self["answer"]) | |
474 | - | |
475 | - | |
476 | 419 | # ============================================================================ |
477 | 420 | class QuestionNumericInterval(Question): |
478 | 421 | '''An instance of QuestionTextNumeric will always have the keys: |
... | ... | @@ -484,9 +427,6 @@ class QuestionNumericInterval(Question): |
484 | 427 | ''' |
485 | 428 | |
486 | 429 | # ------------------------------------------------------------------------ |
487 | - # def __init__(self, q: QDict) -> None: | |
488 | - # super().__init__(q) | |
489 | - | |
490 | 430 | def gen(self) -> None: |
491 | 431 | super().gen() |
492 | 432 | |
... | ... | @@ -548,9 +488,6 @@ class QuestionTextArea(Question): |
548 | 488 | ''' |
549 | 489 | |
550 | 490 | # ------------------------------------------------------------------------ |
551 | - # def __init__(self, q: QDict) -> None: | |
552 | - # super().__init__(q) | |
553 | - | |
554 | 491 | def gen(self) -> None: |
555 | 492 | super().gen() |
556 | 493 | |
... | ... | @@ -561,7 +498,7 @@ class QuestionTextArea(Question): |
561 | 498 | 'args': [] |
562 | 499 | })) |
563 | 500 | |
564 | - self['correct'] = path.join(self['path'], self['correct']) | |
501 | + self['correct'] = os.path.join(self['path'], self['correct']) | |
565 | 502 | |
566 | 503 | # ------------------------------------------------------------------------ |
567 | 504 | def correct(self) -> None: |
... | ... | @@ -625,129 +562,6 @@ class QuestionTextArea(Question): |
625 | 562 | |
626 | 563 | |
627 | 564 | # ============================================================================ |
628 | -# class QuestionCode(Question): | |
629 | -# ''' | |
630 | -# Submits answer to a JOBE server to compile and run against the test cases. | |
631 | -# ''' | |
632 | - | |
633 | -# _outcomes = { | |
634 | -# 0: 'JOBE outcome: Successful run', | |
635 | -# 11: 'JOBE outcome: Compile error', | |
636 | -# 12: 'JOBE outcome: Runtime error', | |
637 | -# 13: 'JOBE outcome: Time limit exceeded', | |
638 | -# 15: 'JOBE outcome: Successful run', | |
639 | -# 17: 'JOBE outcome: Memory limit exceeded', | |
640 | -# 19: 'JOBE outcome: Illegal system call', | |
641 | -# 20: 'JOBE outcome: Internal error, please report', | |
642 | -# 21: 'JOBE outcome: Server overload', | |
643 | -# } | |
644 | - | |
645 | -# # ------------------------------------------------------------------------ | |
646 | -# def __init__(self, q: QDict) -> None: | |
647 | -# super().__init__(q) | |
648 | - | |
649 | -# self.set_defaults(QDict({ | |
650 | -# 'text': '', | |
651 | -# 'timeout': 5, # seconds | |
652 | -# 'server': '127.0.0.1', # JOBE server | |
653 | -# 'language': 'c', | |
654 | -# 'correct': [{'stdin': '', 'stdout': '', 'stderr': '', 'args': ''}], | |
655 | -# })) | |
656 | - | |
657 | - # ------------------------------------------------------------------------ | |
658 | - # def correct(self) -> None: | |
659 | - # super().correct() | |
660 | - | |
661 | - # if self['answer'] is None: | |
662 | - # return | |
663 | - | |
664 | - # # submit answer to JOBE server | |
665 | - # resource = '/jobe/index.php/restapi/runs/' | |
666 | - # headers = {"Content-type": "application/json; charset=utf-8", | |
667 | - # "Accept": "application/json"} | |
668 | - | |
669 | - # for expected in self['correct']: | |
670 | - # data_json = json.dumps({ | |
671 | - # 'run_spec' : { | |
672 | - # 'language_id': self['language'], | |
673 | - # 'sourcecode': self['answer'], | |
674 | - # 'input': expected.get('stdin', ''), | |
675 | - # }, | |
676 | - # }) | |
677 | - | |
678 | - # try: | |
679 | - # connect = http.client.HTTPConnection(self['server']) | |
680 | - # connect.request( | |
681 | - # method='POST', | |
682 | - # url=resource, | |
683 | - # body=data_json, | |
684 | - # headers=headers | |
685 | - # ) | |
686 | - # response = connect.getresponse() | |
687 | - # logger.debug('JOBE response status %d', response.status) | |
688 | - # if response.status != 204: | |
689 | - # content = response.read().decode('utf8') | |
690 | - # if content: | |
691 | - # result = json.loads(content) | |
692 | - # connect.close() | |
693 | - | |
694 | - # except (HTTPError, ValueError): | |
695 | - # logger.error('HTTPError while connecting to JOBE server') | |
696 | - | |
697 | - # try: | |
698 | - # outcome = result['outcome'] | |
699 | - # except (NameError, TypeError, KeyError): | |
700 | - # logger.error('Bad result returned from JOBE server: %s', result) | |
701 | - # return | |
702 | - # logger.debug(self._outcomes[outcome]) | |
703 | - | |
704 | - | |
705 | - | |
706 | - # if result['cmpinfo']: # compiler errors and warnings | |
707 | - # self['comments'] = f'Erros de compilação:\n{result["cmpinfo"]}' | |
708 | - # self['grade'] = 0.0 | |
709 | - # return | |
710 | - | |
711 | - # if result['stdout'] != expected.get('stdout', ''): | |
712 | - # self['comments'] = 'O output gerado é diferente do esperado.' # FIXME mostrar porque? | |
713 | - # self['grade'] = 0.0 | |
714 | - # return | |
715 | - | |
716 | - # self['comments'] = 'Ok!' | |
717 | - # self['grade'] = 1.0 | |
718 | - | |
719 | - | |
720 | - # # ------------------------------------------------------------------------ | |
721 | - # async def correct_async(self) -> None: | |
722 | - # self.correct() # FIXME there is no async correction!!! | |
723 | - | |
724 | - | |
725 | - # out = run_script( | |
726 | - # script=self['correct'], | |
727 | - # args=self['args'], | |
728 | - # stdin=self['answer'], | |
729 | - # timeout=self['timeout'] | |
730 | - # ) | |
731 | - | |
732 | - # if out is None: | |
733 | - # logger.warning('No grade after running "%s".', self["correct"]) | |
734 | - # self['comments'] = 'O programa de correcção abortou...' | |
735 | - # self['grade'] = 0.0 | |
736 | - # elif isinstance(out, dict): | |
737 | - # self['comments'] = out.get('comments', '') | |
738 | - # try: | |
739 | - # self['grade'] = float(out['grade']) | |
740 | - # except ValueError: | |
741 | - # logger.error('Output error in "%s".', self["correct"]) | |
742 | - # except KeyError: | |
743 | - # logger.error('No grade in "%s".', self["correct"]) | |
744 | - # else: | |
745 | - # try: | |
746 | - # self['grade'] = float(out) | |
747 | - # except (TypeError, ValueError): | |
748 | - # logger.error('Invalid grade in "%s".', self["correct"]) | |
749 | - | |
750 | -# ============================================================================ | |
751 | 565 | class QuestionInformation(Question): |
752 | 566 | ''' |
753 | 567 | Not really a question, just an information panel. |
... | ... | @@ -755,9 +569,6 @@ class QuestionInformation(Question): |
755 | 569 | ''' |
756 | 570 | |
757 | 571 | # ------------------------------------------------------------------------ |
758 | - # def __init__(self, q: QDict) -> None: | |
759 | - # super().__init__(q) | |
760 | - | |
761 | 572 | def gen(self) -> None: |
762 | 573 | super().gen() |
763 | 574 | self.set_defaults(QDict({ |
... | ... | @@ -770,7 +581,6 @@ class QuestionInformation(Question): |
770 | 581 | self['grade'] = 1.0 # always "correct" but points should be zero! |
771 | 582 | |
772 | 583 | |
773 | - | |
774 | 584 | # ============================================================================ |
775 | 585 | def question_from(qdict: QDict) -> Question: |
776 | 586 | ''' |
... | ... | @@ -783,7 +593,6 @@ def question_from(qdict: QDict) -> Question: |
783 | 593 | 'text-regex': QuestionTextRegex, |
784 | 594 | 'numeric-interval': QuestionNumericInterval, |
785 | 595 | 'textarea': QuestionTextArea, |
786 | - # 'code': QuestionCode, | |
787 | 596 | # -- informative panels -- |
788 | 597 | 'information': QuestionInformation, |
789 | 598 | 'success': QuestionInformation, |
... | ... | @@ -856,17 +665,17 @@ class QFactory(): |
856 | 665 | logger.debug('generating %s...', self.qdict["ref"]) |
857 | 666 | # Shallow copy so that script generated questions will not replace |
858 | 667 | # the original generators |
859 | - qdict = self.qdict.copy() | |
668 | + qdict = QDict(self.qdict.copy()) | |
860 | 669 | qdict['qid'] = str(uuid.uuid4()) # unique for each question |
861 | 670 | |
862 | 671 | # If question is of generator type, an external program will be run |
863 | 672 | # which will print a valid question in yaml format to stdout. This |
864 | 673 | # output is then yaml parsed into a dictionary `q`. |
865 | 674 | if qdict['type'] == 'generator': |
866 | - logger.debug(' \\_ Running "%s".', qdict['script']) | |
675 | + logger.debug(' \\_ Running "%s"', qdict['script']) | |
867 | 676 | qdict.setdefault('args', []) |
868 | 677 | qdict.setdefault('stdin', '') |
869 | - script = path.join(qdict['path'], qdict['script']) | |
678 | + script = os.path.join(qdict['path'], qdict['script']) | |
870 | 679 | out = await run_script_async(script=script, |
871 | 680 | args=qdict['args'], |
872 | 681 | stdin=qdict['stdin']) |
... | ... | @@ -875,8 +684,3 @@ class QFactory(): |
875 | 684 | question = question_from(qdict) # returns a Question instance |
876 | 685 | question.gen() |
877 | 686 | return question |
878 | - | |
879 | - # ------------------------------------------------------------------------ | |
880 | - def generate(self) -> Question: | |
881 | - '''generate question (synchronous version)''' | |
882 | - return asyncio.get_event_loop().run_until_complete(self.gen_async()) | ... | ... |
perguntations/serve.py
... | ... | @@ -10,23 +10,27 @@ import asyncio |
10 | 10 | import base64 |
11 | 11 | import functools |
12 | 12 | import json |
13 | -import logging.config | |
13 | +import logging | |
14 | 14 | import mimetypes |
15 | 15 | from os import path |
16 | 16 | import re |
17 | 17 | import signal |
18 | 18 | import sys |
19 | 19 | from timeit import default_timer as timer |
20 | +from typing import Dict, Tuple | |
20 | 21 | import uuid |
21 | 22 | |
22 | 23 | # user installed libraries |
23 | 24 | import tornado.ioloop |
24 | 25 | import tornado.web |
25 | -# import tornado.websocket | |
26 | 26 | import tornado.httpserver |
27 | 27 | |
28 | 28 | # this project |
29 | -from perguntations.parser_markdown import md_to_html | |
29 | +from .parser_markdown import md_to_html | |
30 | + | |
31 | + | |
32 | +# setup logger for this module | |
33 | +logger = logging.getLogger(__name__) | |
30 | 34 | |
31 | 35 | |
32 | 36 | # ---------------------------------------------------------------------------- |
... | ... | @@ -41,8 +45,6 @@ class WebApplication(tornado.web.Application): |
41 | 45 | (r'/review', ReviewHandler), |
42 | 46 | (r'/admin', AdminHandler), |
43 | 47 | (r'/file', FileHandler), |
44 | - # (r'/root', MainHandler), | |
45 | - # (r'/ws', AdminSocketHandler), | |
46 | 48 | (r'/adminwebservice', AdminWebservice), |
47 | 49 | (r'/studentwebservice', StudentWebservice), |
48 | 50 | (r'/', RootHandler), |
... | ... | @@ -64,11 +66,10 @@ class WebApplication(tornado.web.Application): |
64 | 66 | # ---------------------------------------------------------------------------- |
65 | 67 | def admin_only(func): |
66 | 68 | ''' |
67 | - Decorator used to restrict access to the administrator. | |
68 | - Example: | |
69 | + Decorator to restrict access to the administrator: | |
69 | 70 | |
70 | - @admin_only | |
71 | - def get(self): ... | |
71 | + @admin_only | |
72 | + def get(self): | |
72 | 73 | ''' |
73 | 74 | @functools.wraps(func) |
74 | 75 | async def wrapper(self, *args, **kwargs): |
... | ... | @@ -92,6 +93,11 @@ class BaseHandler(tornado.web.RequestHandler): |
92 | 93 | '''simplifies access to the application a little bit''' |
93 | 94 | return self.application.testapp |
94 | 95 | |
96 | + # @property | |
97 | + # def debug(self) -> bool: | |
98 | + # '''check if is running in debug mode''' | |
99 | + # return self.application.testapp.debug | |
100 | + | |
95 | 101 | def get_current_user(self): |
96 | 102 | ''' |
97 | 103 | Since HTTP is stateless, a cookie is used to identify the user. |
... | ... | @@ -104,72 +110,15 @@ class BaseHandler(tornado.web.RequestHandler): |
104 | 110 | |
105 | 111 | |
106 | 112 | # ---------------------------------------------------------------------------- |
107 | -# class MainHandler(BaseHandler): | |
108 | - | |
109 | -# @tornado.web.authenticated | |
110 | -# @admin_only | |
111 | -# def get(self): | |
112 | -# self.render("admin-ws.html", students=self.testapp.get_students_state()) | |
113 | - | |
114 | - | |
115 | -# # ---------------------------------------------------------------------------- | |
116 | -# class AdminSocketHandler(tornado.websocket.WebSocketHandler): | |
117 | -# waiters = set() | |
118 | -# # cache = [] | |
119 | - | |
120 | -# # def get_compression_options(self): | |
121 | -# # return {} # Non-None enables compression with default options. | |
122 | - | |
123 | -# # called when opening connection | |
124 | -# def open(self): | |
125 | -# logging.debug('[AdminSocketHandler.open]') | |
126 | -# AdminSocketHandler.waiters.add(self) | |
127 | - | |
128 | -# # called when closing connection | |
129 | -# def on_close(self): | |
130 | -# logging.debug('[AdminSocketHandler.on_close]') | |
131 | -# AdminSocketHandler.waiters.remove(self) | |
132 | - | |
133 | -# # @classmethod | |
134 | -# # def update_cache(cls, chat): | |
135 | -# # logging.debug(f'[AdminSocketHandler.update_cache] "{chat}"') | |
136 | -# # cls.cache.append(chat) | |
137 | - | |
138 | -# # @classmethod | |
139 | -# # def send_updates(cls, chat): | |
140 | -# # logging.info("sending message to %d waiters", len(cls.waiters)) | |
141 | -# # for waiter in cls.waiters: | |
142 | -# # try: | |
143 | -# # waiter.write_message(chat) | |
144 | -# # except Exception: | |
145 | -# # logging.error("Error sending message", exc_info=True) | |
146 | - | |
147 | -# # handle incomming messages | |
148 | -# def on_message(self, message): | |
149 | -# logging.info(f"[AdminSocketHandler.onmessage] got message {message}") | |
150 | -# parsed = tornado.escape.json_decode(message) | |
151 | -# print(parsed) | |
152 | -# chat = {"id": str(uuid.uuid4()), "body": parsed["body"]} | |
153 | -# print(chat) | |
154 | -# chat["html"] = tornado.escape.to_basestring( | |
155 | -# '<div>' + chat['body'] + '</div>' | |
156 | -# # self.render_string("message.html", message=chat) | |
157 | -# ) | |
158 | -# print(chat) | |
159 | - | |
160 | -# AdminSocketHandler.update_cache(chat) # store msgs | |
161 | -# AdminSocketHandler.send_updates(chat) # send to clients | |
162 | - | |
163 | -# ---------------------------------------------------------------------------- | |
164 | 113 | # pylint: disable=abstract-method |
165 | 114 | class LoginHandler(BaseHandler): |
166 | 115 | '''Handles /login''' |
167 | 116 | |
168 | 117 | _prefix = re.compile(r'[a-z]') |
169 | 118 | _error_msg = { |
170 | - 'wrong_password': 'Password errada', | |
171 | - 'already_online': 'Já está online, não pode entrar duas vezes', | |
172 | - 'unauthorized': 'Não está autorizado a fazer o teste' | |
119 | + 'wrong_password': 'Senha errada', | |
120 | + 'not_allowed': 'Não está autorizado a fazer o teste', | |
121 | + 'nonexistent': 'Número de aluno inválido' | |
173 | 122 | } |
174 | 123 | |
175 | 124 | def get(self): |
... | ... | @@ -178,7 +127,7 @@ class LoginHandler(BaseHandler): |
178 | 127 | |
179 | 128 | async def post(self): |
180 | 129 | '''Authenticates student and login.''' |
181 | - uid = self._prefix.sub('', self.get_body_argument('uid')) | |
130 | + uid = self.get_body_argument('uid') | |
182 | 131 | password = self.get_body_argument('pw') |
183 | 132 | headers = { |
184 | 133 | 'remote_ip': self.request.remote_ip, |
... | ... | @@ -187,7 +136,7 @@ class LoginHandler(BaseHandler): |
187 | 136 | |
188 | 137 | error = await self.testapp.login(uid, password, headers) |
189 | 138 | |
190 | - if error: | |
139 | + if error is not None: | |
191 | 140 | await asyncio.sleep(3) # delay to avoid spamming the server... |
192 | 141 | self.render('login.html', error=self._error_msg[error]) |
193 | 142 | else: |
... | ... | @@ -203,8 +152,8 @@ class LogoutHandler(BaseHandler): |
203 | 152 | @tornado.web.authenticated |
204 | 153 | def get(self): |
205 | 154 | '''Logs out a user.''' |
206 | - self.clear_cookie('perguntations_user') | |
207 | 155 | self.testapp.logout(self.current_user) |
156 | + self.clear_cookie('perguntations_user') | |
208 | 157 | self.render('login.html', error='') |
209 | 158 | |
210 | 159 | |
... | ... | @@ -214,7 +163,7 @@ class LogoutHandler(BaseHandler): |
214 | 163 | # pylint: disable=abstract-method |
215 | 164 | class RootHandler(BaseHandler): |
216 | 165 | ''' |
217 | - Generates test to student. | |
166 | + Presents test to student. | |
218 | 167 | Receives answers, corrects the test and sends back the grade. |
219 | 168 | Redirects user 0 to /admin. |
220 | 169 | ''' |
... | ... | @@ -227,7 +176,6 @@ class RootHandler(BaseHandler): |
227 | 176 | 'text-regex': 'question-text.html', |
228 | 177 | 'numeric-interval': 'question-text.html', |
229 | 178 | 'textarea': 'question-textarea.html', |
230 | - 'code': 'question-textarea.html', | |
231 | 179 | # -- information panels -- |
232 | 180 | 'information': 'question-information.html', |
233 | 181 | 'success': 'question-information.html', |
... | ... | @@ -243,77 +191,67 @@ class RootHandler(BaseHandler): |
243 | 191 | Sends test to student or redirects 0 to admin page. |
244 | 192 | Multiple calls to this function will return the same test. |
245 | 193 | ''' |
246 | - | |
247 | 194 | uid = self.current_user |
248 | - logging.debug('"%s" GET /', uid) | |
195 | + logger.debug('"%s" GET /', uid) | |
249 | 196 | |
250 | 197 | if uid == '0': |
251 | 198 | self.redirect('/admin') |
252 | - return | |
253 | - | |
254 | - test = await self.testapp.get_test_or_generate(uid) | |
255 | - self.render('test.html', t=test, md=md_to_html, templ=self._templates) | |
256 | - | |
199 | + else: | |
200 | + test = self.testapp.get_test(uid) | |
201 | + name = self.testapp.get_name(uid) | |
202 | + self.render('test.html', t=test, uid=uid, name=name, md=md_to_html, | |
203 | + templ=self._templates, debug=self.testapp.debug) | |
257 | 204 | |
258 | 205 | # --- POST |
259 | 206 | @tornado.web.authenticated |
260 | 207 | async def post(self): |
261 | 208 | ''' |
262 | 209 | Receives answers, fixes some html weirdness, corrects test and |
263 | - sends back the grade. | |
210 | + renders the grade. | |
264 | 211 | |
265 | 212 | self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} |
266 | - builds dictionary ans={0: 'answer0', 1:, 'answer1', ...} | |
267 | - unanswered questions not included. | |
213 | + builds dictionary ans = {0: 'answer0', 1:, 'answer1', ...} | |
214 | + unanswered questions are not included. | |
268 | 215 | ''' |
269 | - timeit_start = timer() # performance timer | |
216 | + starttime = timer() # performance timer | |
270 | 217 | |
271 | 218 | uid = self.current_user |
272 | - logging.debug('"%s" POST /', uid) | |
219 | + logger.debug('"%s" POST /', uid) | |
273 | 220 | |
274 | - try: | |
275 | - test = self.testapp.get_test(uid) | |
276 | - except KeyError as exc: | |
277 | - logging.warning('"%s" POST / raised 403 Forbidden', uid) | |
278 | - raise tornado.web.HTTPError(403) from exc # Forbidden | |
221 | + test = self.testapp.get_test(uid) | |
222 | + if test is None: | |
223 | + logger.warning('"%s" submitted but no test running - Err 403', uid) | |
224 | + raise tornado.web.HTTPError(403) # Forbidden | |
279 | 225 | |
280 | 226 | ans = {} |
281 | 227 | for i, question in enumerate(test['questions']): |
282 | 228 | qid = str(i) |
283 | - if 'answered-' + qid in self.request.arguments: | |
229 | + if f'answered-{qid}' in self.request.arguments: | |
284 | 230 | ans[i] = self.get_body_arguments(qid) |
285 | 231 | |
286 | 232 | # remove enclosing list in some question types |
287 | 233 | if question['type'] == 'radio': |
288 | - if not ans[i]: | |
289 | - ans[i] = None | |
290 | - else: | |
291 | - ans[i] = ans[i][0] | |
234 | + ans[i] = ans[i][0] if ans[i] else None | |
292 | 235 | elif question['type'] in ('text', 'text-regex', 'textarea', |
293 | - 'numeric-interval', 'code'): | |
236 | + 'numeric-interval'): | |
294 | 237 | ans[i] = ans[i][0] |
295 | 238 | |
296 | 239 | # submit answered questions, correct |
297 | 240 | await self.testapp.submit_test(uid, ans) |
298 | 241 | |
299 | - # show final grade and grades of other tests in the database | |
300 | - # allgrades = self.testapp.get_student_grades_from_all_tests(uid) | |
301 | - # grade = self.testapp.get_student_grade(uid) | |
302 | - | |
303 | - self.render('grade.html', t=test) | |
242 | + name = self.testapp.get_name(uid) | |
243 | + self.render('grade.html', t=test, uid=uid, name=name) | |
304 | 244 | self.clear_cookie('perguntations_user') |
305 | 245 | self.testapp.logout(uid) |
306 | - | |
307 | - timeit_finish = timer() | |
308 | - logging.info(' elapsed time: %fs', timeit_finish-timeit_start) | |
246 | + logger.info(' elapsed time: %fs', timer() - starttime) | |
309 | 247 | |
310 | 248 | |
311 | 249 | # ---------------------------------------------------------------------------- |
312 | 250 | # pylint: disable=abstract-method |
313 | 251 | class StudentWebservice(BaseHandler): |
314 | 252 | ''' |
315 | - Receive ajax from students in the test in response from focus, unfocus and | |
316 | - resize events. | |
253 | + Receive ajax from students during the test in response to the events | |
254 | + focus, unfocus and resize. | |
317 | 255 | ''' |
318 | 256 | |
319 | 257 | @tornado.web.authenticated |
... | ... | @@ -321,8 +259,9 @@ class StudentWebservice(BaseHandler): |
321 | 259 | '''handle ajax post''' |
322 | 260 | uid = self.current_user |
323 | 261 | cmd = self.get_body_argument('cmd', None) |
324 | - value = json.loads(self.get_body_argument('value', None)) | |
325 | - self.testapp.event_test(uid, cmd, value) | |
262 | + value = self.get_body_argument('value', None) | |
263 | + if cmd is not None and value is not None: | |
264 | + self.testapp.register_event(uid, cmd, json.loads(value)) | |
326 | 265 | |
327 | 266 | |
328 | 267 | # ---------------------------------------------------------------------------- |
... | ... | @@ -337,16 +276,17 @@ class AdminWebservice(BaseHandler): |
337 | 276 | async def get(self): |
338 | 277 | '''admin webservices that do not change state''' |
339 | 278 | cmd = self.get_query_argument('cmd') |
279 | + logger.debug('GET /adminwebservice %s', cmd) | |
280 | + | |
340 | 281 | if cmd == 'testcsv': |
341 | - test_ref, data = self.testapp.get_test_csv() | |
282 | + test_ref, data = self.testapp.get_grades_csv() | |
342 | 283 | self.set_header('Content-Type', 'text/csv') |
343 | 284 | self.set_header('content-Disposition', |
344 | 285 | f'attachment; filename={test_ref}.csv') |
345 | 286 | self.write(data) |
346 | 287 | await self.flush() |
347 | - | |
348 | - if cmd == 'questionscsv': | |
349 | - test_ref, data = self.testapp.get_questions_csv() | |
288 | + elif cmd == 'questionscsv': | |
289 | + test_ref, data = self.testapp.get_detailed_grades_csv() | |
350 | 290 | self.set_header('Content-Type', 'text/csv') |
351 | 291 | self.set_header('content-Disposition', |
352 | 292 | f'attachment; filename={test_ref}-detailed.csv') |
... | ... | @@ -359,6 +299,7 @@ class AdminWebservice(BaseHandler): |
359 | 299 | class AdminHandler(BaseHandler): |
360 | 300 | '''Handle /admin''' |
361 | 301 | |
302 | + # --- GET | |
362 | 303 | @tornado.web.authenticated |
363 | 304 | @admin_only |
364 | 305 | async def get(self): |
... | ... | @@ -366,24 +307,18 @@ class AdminHandler(BaseHandler): |
366 | 307 | Admin page. |
367 | 308 | ''' |
368 | 309 | cmd = self.get_query_argument('cmd', default=None) |
310 | + logger.debug('GET /admin (cmd=%s)', cmd) | |
369 | 311 | |
370 | - if cmd == 'students_table': | |
371 | - data = {'data': self.testapp.get_students_state()} | |
372 | - self.write(json.dumps(data, default=str)) | |
312 | + if cmd is None: | |
313 | + self.render('admin.html') | |
373 | 314 | elif cmd == 'test': |
374 | - data = { | |
375 | - 'data': { | |
376 | - 'title': self.testapp.testfactory['title'], | |
377 | - 'ref': self.testapp.testfactory['ref'], | |
378 | - 'filename': self.testapp.testfactory['testfile'], | |
379 | - 'database': self.testapp.testfactory['database'], | |
380 | - 'answers_dir': self.testapp.testfactory['answers_dir'], | |
381 | - } | |
382 | - } | |
315 | + data = { 'data': self.testapp.get_test_config() } | |
316 | + self.write(json.dumps(data, default=str)) | |
317 | + elif cmd == 'students_table': | |
318 | + data = {'data': self.testapp.get_students_state()} | |
383 | 319 | self.write(json.dumps(data, default=str)) |
384 | - else: | |
385 | - self.render('admin.html') | |
386 | 320 | |
321 | + # --- POST | |
387 | 322 | @tornado.web.authenticated |
388 | 323 | @admin_only |
389 | 324 | async def post(self): |
... | ... | @@ -392,6 +327,7 @@ class AdminHandler(BaseHandler): |
392 | 327 | ''' |
393 | 328 | cmd = self.get_body_argument('cmd', None) |
394 | 329 | value = self.get_body_argument('value', None) |
330 | + logger.debug('POST /admin (cmd=%s, value=%s)') | |
395 | 331 | |
396 | 332 | if cmd == 'allow': |
397 | 333 | self.testapp.allow_student(value) |
... | ... | @@ -402,15 +338,11 @@ class AdminHandler(BaseHandler): |
402 | 338 | elif cmd == 'deny_all': |
403 | 339 | self.testapp.deny_all_students() |
404 | 340 | elif cmd == 'reset_password': |
405 | - await self.testapp.update_student_password(uid=value, password='') | |
406 | - | |
407 | - elif cmd == 'insert_student': | |
341 | + await self.testapp.set_password(uid=value, pw='') | |
342 | + elif cmd == 'insert_student' and value is not None: | |
408 | 343 | student = json.loads(value) |
409 | - self.testapp.insert_new_student(uid=student['number'], | |
410 | - name=student['name']) | |
411 | - | |
412 | - else: | |
413 | - logging.error('Unknown command: "%s"', cmd) | |
344 | + await self.testapp.insert_new_student(uid=student['number'], | |
345 | + name=student['name']) | |
414 | 346 | |
415 | 347 | |
416 | 348 | # ---------------------------------------------------------------------------- |
... | ... | @@ -422,6 +354,7 @@ class FileHandler(BaseHandler): |
422 | 354 | Handles static files from questions like images, etc. |
423 | 355 | ''' |
424 | 356 | |
357 | + _filecache: Dict[Tuple[str, str], bytes] = {} | |
425 | 358 | |
426 | 359 | @tornado.web.authenticated |
427 | 360 | async def get(self): |
... | ... | @@ -429,40 +362,47 @@ class FileHandler(BaseHandler): |
429 | 362 | Returns requested file. Files are obtained from the 'public' directory |
430 | 363 | of each question. |
431 | 364 | ''' |
432 | - | |
433 | 365 | uid = self.current_user |
434 | 366 | ref = self.get_query_argument('ref', None) |
435 | 367 | image = self.get_query_argument('image', None) |
368 | + logger.debug('GET /file (ref=%s, image=%s)', ref, image) | |
369 | + | |
370 | + if ref is None or image is None: | |
371 | + return | |
372 | + | |
436 | 373 | content_type = mimetypes.guess_type(image)[0] |
437 | 374 | |
438 | - if uid != '0': | |
439 | - test = self.testapp.get_student_test(uid) | |
440 | - else: | |
441 | - logging.error('FIXME Cannot serve images for review.') | |
442 | - raise tornado.web.HTTPError(404) # FIXME admin | |
375 | + if (ref, image) in self._filecache: | |
376 | + logger.debug('using cached file') | |
377 | + self.write(self._filecache[(ref, image)]) | |
378 | + if content_type is not None: | |
379 | + self.set_header("Content-Type", content_type) | |
380 | + await self.flush() | |
381 | + return | |
443 | 382 | |
444 | - if test is None: | |
445 | - raise tornado.web.HTTPError(404) # Not Found | |
383 | + try: | |
384 | + test = self.testapp.get_test(uid) | |
385 | + except KeyError: | |
386 | + logger.warning('Could not get test to serve image file') | |
387 | + raise tornado.web.HTTPError(404) from None # Not Found | |
446 | 388 | |
389 | + # search for the question that contains the image | |
447 | 390 | for question in test['questions']: |
448 | - # search for the question that contains the image | |
449 | 391 | if question['ref'] == ref: |
450 | 392 | filepath = path.join(question['path'], 'public', image) |
393 | + | |
451 | 394 | try: |
452 | - file = open(filepath, 'rb') | |
453 | - except FileNotFoundError: | |
454 | - logging.error('File not found: %s', filepath) | |
455 | - except PermissionError: | |
456 | - logging.error('No permission: %s', filepath) | |
395 | + with open(filepath, 'rb') as file: | |
396 | + data = file.read() | |
457 | 397 | except OSError: |
458 | - logging.error('Error opening file: %s', filepath) | |
459 | - else: | |
460 | - data = file.read() | |
461 | - file.close() | |
398 | + logger.error('Error reading file "%s"', filepath) | |
399 | + return | |
400 | + self._filecache[(ref, image)] = data | |
401 | + self.write(data) | |
402 | + if content_type is not None: | |
462 | 403 | self.set_header("Content-Type", content_type) |
463 | - self.write(data) | |
464 | - await self.flush() | |
465 | - break | |
404 | + await self.flush() | |
405 | + return | |
466 | 406 | |
467 | 407 | |
468 | 408 | # --- REVIEW ----------------------------------------------------------------- |
... | ... | @@ -479,7 +419,6 @@ class ReviewHandler(BaseHandler): |
479 | 419 | 'text-regex': 'review-question-text.html', |
480 | 420 | 'numeric-interval': 'review-question-text.html', |
481 | 421 | 'textarea': 'review-question-text.html', |
482 | - 'code': 'review-question-text.html', | |
483 | 422 | # -- information panels -- |
484 | 423 | 'information': 'review-question-information.html', |
485 | 424 | 'success': 'review-question-information.html', |
... | ... | @@ -494,26 +433,28 @@ class ReviewHandler(BaseHandler): |
494 | 433 | Opens JSON file with a given corrected test and renders it |
495 | 434 | ''' |
496 | 435 | test_id = self.get_query_argument('test_id', None) |
497 | - logging.info('Review test %s.', test_id) | |
436 | + logger.info('Review test %s.', test_id) | |
498 | 437 | fname = self.testapp.get_json_filename_of_test(test_id) |
499 | 438 | |
500 | 439 | if fname is None: |
501 | 440 | raise tornado.web.HTTPError(404) # Not Found |
502 | 441 | |
503 | 442 | try: |
504 | - with open(path.expanduser(fname)) as jsonfile: | |
443 | + with open(path.expanduser(fname), encoding='utf-8') as jsonfile: | |
505 | 444 | test = json.load(jsonfile) |
506 | 445 | except OSError: |
507 | 446 | msg = f'Cannot open "{fname}" for review.' |
508 | - logging.error(msg) | |
447 | + logger.error(msg) | |
509 | 448 | raise tornado.web.HTTPError(status_code=404, reason=msg) from None |
510 | 449 | except json.JSONDecodeError as exc: |
511 | 450 | msg = f'JSON error in "{fname}": {exc}' |
512 | - logging.error(msg) | |
451 | + logger.error(msg) | |
513 | 452 | raise tornado.web.HTTPError(status_code=404, reason=msg) |
514 | 453 | |
515 | - self.render('review.html', t=test, md=md_to_html, | |
516 | - templ=self._templates) | |
454 | + uid = test['student'] | |
455 | + name = self.testapp.get_name(uid) | |
456 | + self.render('review.html', t=test, uid=uid, name=name, md=md_to_html, | |
457 | + templ=self._templates, debug=self.testapp.debug) | |
517 | 458 | |
518 | 459 | |
519 | 460 | # ---------------------------------------------------------------------------- |
... | ... | @@ -524,7 +465,7 @@ def signal_handler(*_): |
524 | 465 | reply = input(' --> Stop webserver? (yes/no) ') |
525 | 466 | if reply.lower() == 'yes': |
526 | 467 | tornado.ioloop.IOLoop.current().stop() |
527 | - logging.critical('Webserver stopped.') | |
468 | + logger.critical('Webserver stopped.') | |
528 | 469 | sys.exit(0) |
529 | 470 | |
530 | 471 | # ---------------------------------------------------------------------------- |
... | ... | @@ -534,33 +475,33 @@ def run_webserver(app, ssl_opt, port, debug): |
534 | 475 | ''' |
535 | 476 | |
536 | 477 | # --- create web application |
537 | - logging.info('-----------------------------------------------------------') | |
538 | - logging.info('Starting WebApplication (tornado)') | |
478 | + logger.info('-------- Starting WebApplication (tornado) --------') | |
539 | 479 | try: |
540 | 480 | webapp = WebApplication(app, debug=debug) |
541 | 481 | except Exception: |
542 | - logging.critical('Failed to start web application.') | |
482 | + logger.critical('Failed to start web application.') | |
543 | 483 | raise |
544 | 484 | |
485 | + # --- create httpserver | |
545 | 486 | try: |
546 | 487 | httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt) |
547 | 488 | except ValueError: |
548 | - logging.critical('Certificates cert.pem, privkey.pem not found') | |
489 | + logger.critical('Certificates cert.pem, privkey.pem not found') | |
549 | 490 | sys.exit(1) |
550 | 491 | |
551 | 492 | try: |
552 | 493 | httpserver.listen(port) |
553 | 494 | except OSError: |
554 | - logging.critical('Cannot bind port %d. Already in use?', port) | |
495 | + logger.critical('Cannot bind port %d. Already in use?', port) | |
555 | 496 | sys.exit(1) |
556 | 497 | |
557 | - logging.info('Webserver listening on %d... (Ctrl-C to stop)', port) | |
498 | + logger.info('Listening on port %d... (Ctrl-C to stop)', port) | |
558 | 499 | signal.signal(signal.SIGINT, signal_handler) |
559 | 500 | |
560 | 501 | # --- run webserver |
561 | 502 | try: |
562 | 503 | tornado.ioloop.IOLoop.current().start() # running... |
563 | 504 | except Exception: |
564 | - logging.critical('Webserver stopped!') | |
505 | + logger.critical('Webserver stopped!') | |
565 | 506 | tornado.ioloop.IOLoop.current().stop() |
566 | 507 | raise | ... | ... |
perguntations/static/js/admin.js
... | ... | @@ -117,16 +117,13 @@ $(document).ready(function() { |
117 | 117 | d = json.data[i]; |
118 | 118 | var uid = d['uid']; |
119 | 119 | var checked = d['allowed'] ? 'checked' : ''; |
120 | - var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : ''; | |
120 | + // var password_defined = d['password_defined'] ? ' <span class="badge badge-secondary"><i class="fa fa-key" aria-hidden="true"></i></span>' : ''; | |
121 | 121 | var hora_inicio = d['start_time'] ? ' <span class="badge badge-success"><i class="fas fa-hourglass-start"></i> ' + d['start_time'].slice(11,16) + '</span>': ''; |
122 | 122 | var unfocus = d['unfocus'] ? ' <span class="badge badge-danger">unfocus</span>' : ''; |
123 | - var area = ''; | |
124 | - if (d['start_time'] ) { | |
125 | - if (d['area'] > 75) | |
126 | - area = ' <span class="badge badge-success"><i class="fas fa-desktop"></i> ' + Math.round(d['area']) + '%</span>'; | |
127 | - else | |
128 | - area = ' <span class="badge badge-danger"><i class="fas fa-desktop"></i> ' + Math.round(d['area']) + '%</span>'; | |
129 | - }; | |
123 | + if (d['area'] > 75) | |
124 | + area = ' <span class="badge badge-success"><i class="fas fa-desktop"></i>' + Math.round(d['area']) + '%</span>'; | |
125 | + else | |
126 | + area = ' <span class="badge badge-danger"><i class="fas fa-desktop"></i> ' + Math.round(d['area']) + '%</span>'; | |
130 | 127 | var g = d['grades']; |
131 | 128 | |
132 | 129 | t[i] = []; |
... | ... | @@ -134,7 +131,7 @@ $(document).ready(function() { |
134 | 131 | t[i][1] = '<input type="checkbox" name="' + uid + '" value="true"' + checked + '> '; |
135 | 132 | t[i][2] = uid; |
136 | 133 | t[i][3] = d['name']; |
137 | - t[i][4] = password_defined + hora_inicio + area + unfocus; | |
134 | + t[i][4] = d['online'] ? hora_inicio + area + unfocus : ''; | |
138 | 135 | |
139 | 136 | var gbar = ''; |
140 | 137 | for (var j=0; j < g.length; j++) | ... | ... |
perguntations/templates/admin.html
... | ... | @@ -53,8 +53,8 @@ |
53 | 53 | Acções |
54 | 54 | </a> |
55 | 55 | <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownAluno"> |
56 | - <a class="dropdown-item" href="#" id="novo_aluno" data-toggle="modal" data-target="#novo_aluno_modal">Inserir novo aluno...</a> | |
57 | - <a class="dropdown-item" href="#" id="reset_password_menu" data-toggle="modal" data-target="#reset_password_modal">Reset password do aluno...</a> | |
56 | + <a class="dropdown-item" href="#" id="novo_aluno" data-toggle="modal" data-target="#novo_aluno_modal">Novo aluno...</a> | |
57 | + <a class="dropdown-item" href="#" id="reset_password_menu" data-toggle="modal" data-target="#reset_password_modal">Limpar password...</a> | |
58 | 58 | <a class="dropdown-item" href="#" id="allow_all">Autorizar todos</a> |
59 | 59 | <a class="dropdown-item" href="#" id="deny_all">Desautorizar todos</a> |
60 | 60 | <div class="dropdown-divider"></div> |
... | ... | @@ -72,7 +72,7 @@ |
72 | 72 | <p> |
73 | 73 | Referência: <code id="ref">--</code><br> |
74 | 74 | Ficheiro de configuração do teste: <code id="filename">--</code><br> |
75 | - Testes em formato JSON no directório: <code id="answers_dir">--</code><br> | |
75 | + Directório com os testes entregues: <code id="answers_dir">--</code><br> | |
76 | 76 | Base de dados: <code id="database">--</code><br> |
77 | 77 | </p> |
78 | 78 | <p> | ... | ... |
perguntations/templates/grade.html
... | ... | @@ -31,8 +31,8 @@ |
31 | 31 | </ul> |
32 | 32 | <span class="navbar-text"> |
33 | 33 | <i class="fas fa-user" aria-hidden="true"></i> |
34 | - <span id="name">{{ escape(t['student']['name']) }}</span> | |
35 | - (<span id="number">{{ escape(t['student']['number']) }}</span>) | |
34 | + <span id="name">{{ escape(name) }}</span> | |
35 | + (<span id="number">{{ escape(uid) }}</span>) | |
36 | 36 | <span class="caret"></span> |
37 | 37 | </span> |
38 | 38 | </div> | ... | ... |
perguntations/templates/question-information.html
... | ... | @@ -17,9 +17,9 @@ |
17 | 17 | {{ md(q['text']) }} |
18 | 18 | </div> |
19 | 19 | |
20 | - {% if show_ref %} | |
20 | + {% if debug %} | |
21 | 21 | <hr> |
22 | 22 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
23 | 23 | ref: <code>{{ q['ref'] }}</code> |
24 | 24 | {% end %} |
25 | -</div> | |
26 | 25 | \ No newline at end of file |
26 | +</div> | ... | ... |
perguntations/templates/question.html
... | ... | @@ -29,11 +29,11 @@ |
29 | 29 | </p> |
30 | 30 | </div> |
31 | 31 | |
32 | - {% if show_ref %} | |
32 | + {% if debug %} | |
33 | 33 | <div class="card-footer"> |
34 | 34 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
35 | 35 | ref: <code>{{ q['ref'] }}</code> |
36 | 36 | </div> |
37 | 37 | {% end %} |
38 | 38 | </div> |
39 | -{% end %} | |
40 | 39 | \ No newline at end of file |
40 | +{% end %} | ... | ... |
perguntations/templates/review-question-information.html
... | ... | @@ -16,9 +16,9 @@ |
16 | 16 | <div id="text"> |
17 | 17 | {{ md(q['text']) }} |
18 | 18 | </div> |
19 | - {% if t['show_ref'] %} | |
19 | + {% if debug %} | |
20 | 20 | <hr> |
21 | 21 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
22 | 22 | ref: <code>{{ q['ref'] }}</code> |
23 | 23 | {% end %} |
24 | -</div> | |
25 | 24 | \ No newline at end of file |
25 | +</div> | ... | ... |
perguntations/templates/review-question.html
... | ... | @@ -65,7 +65,7 @@ |
65 | 65 | {% end %} |
66 | 66 | {% end %} |
67 | 67 | |
68 | - {% if t['show_ref'] %} | |
68 | + {% if debug %} | |
69 | 69 | <hr> |
70 | 70 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
71 | 71 | ref: <code>{{ q['ref'] }}</code> |
... | ... | @@ -109,7 +109,7 @@ |
109 | 109 | {% end %} |
110 | 110 | </p> |
111 | 111 | |
112 | - {% if t['show_ref'] %} | |
112 | + {% if debug %} | |
113 | 113 | <hr> |
114 | 114 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
115 | 115 | ref: <code>{{ q['ref'] }}</code> |
... | ... | @@ -118,4 +118,4 @@ |
118 | 118 | </div> <!-- card-footer --> |
119 | 119 | </div> <!-- card --> |
120 | 120 | {% end %} <!-- if answer not None --> |
121 | -{% end %} <!-- block --> | |
122 | 121 | \ No newline at end of file |
122 | +{% end %} <!-- block --> | ... | ... |
perguntations/templates/review.html
... | ... | @@ -59,8 +59,8 @@ |
59 | 59 | <li class="nav-item"> |
60 | 60 | <span class="navbar-text"> |
61 | 61 | <i class="fas fa-user" aria-hidden="true"></i> |
62 | - <span id="name">{{ escape(t['student']['name']) }}</span> | |
63 | - (<span id="number">{{ escape(t['student']['number']) }}</span>) | |
62 | + <span id="name">{{ escape(name) }}</span> | |
63 | + (<span id="number">{{ escape(uid) }}</span>) | |
64 | 64 | <span class="caret"></span> |
65 | 65 | </span> |
66 | 66 | </li> |
... | ... | @@ -97,9 +97,12 @@ |
97 | 97 | <div class="row"> |
98 | 98 | <label for="nota" class="col-sm-2">Nota:</label> |
99 | 99 | <div class="col-sm-10" id="nota"> |
100 | - <span class="badge badge-primary">{{ round(t['grade'], 2) }}</span> valores | |
101 | - {% if t['state'] == 'QUIT' %} | |
102 | - (DESISTÊNCIA) | |
100 | + {% if t['state'] == 'CORRECTED' %} | |
101 | + <span class="badge badge-primary">{{ round(t['grade'], 2) }}</span> valores | |
102 | + {% elif t['state'] == 'SUBMITTED' %} | |
103 | + (não corrigido) | |
104 | + {% elif t['state'] == 'QUIT' %} | |
105 | + (DESISTIU) | |
103 | 106 | {% end %} |
104 | 107 | </div> |
105 | 108 | </div> |
... | ... | @@ -113,7 +116,7 @@ |
113 | 116 | </div> <!-- jumbotron --> |
114 | 117 | |
115 | 118 | {% for i, q in enumerate(t['questions']) %} |
116 | - {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), t=t) %} | |
119 | + {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), t=t, debug=debug) %} | |
117 | 120 | {% end %} |
118 | 121 | |
119 | 122 | </div> <!-- container --> | ... | ... |
perguntations/templates/test.html
... | ... | @@ -74,8 +74,8 @@ |
74 | 74 | <li class="nav-item"> |
75 | 75 | <span class="navbar-text"> |
76 | 76 | <i class="fas fa-user" aria-hidden="true"></i> |
77 | - <span id="name">{{ escape(t['student']['name']) }}</span> | |
78 | - (<span id="number">{{ escape(t['student']['number']) }}</span>) | |
77 | + <span id="name">{{ escape(name) }}</span> | |
78 | + (<span id="number">{{ escape(uid) }}</span>) | |
79 | 79 | <span class="caret"></span> |
80 | 80 | </span> |
81 | 81 | </li> |
... | ... | @@ -93,11 +93,11 @@ |
93 | 93 | |
94 | 94 | <div class="row"> |
95 | 95 | <label for="nome" class="col-sm-3">Nome:</label> |
96 | - <div class="col-sm-9" id="nome">{{ escape(t['student']['name']) }}</div> | |
96 | + <div class="col-sm-9" id="nome">{{ escape(name) }}</div> | |
97 | 97 | </div> |
98 | 98 | <div class="row"> |
99 | 99 | <label for="numero" class="col-sm-3">Número:</label> |
100 | - <div class="col-sm-9" id="numero">{{ escape(t['student']['number']) }}</div> | |
100 | + <div class="col-sm-9" id="numero">{{ escape(uid) }}</div> | |
101 | 101 | </div> |
102 | 102 | |
103 | 103 | <div class="row"> |
... | ... | @@ -114,12 +114,14 @@ |
114 | 114 | {% module xsrf_form_html() %} |
115 | 115 | |
116 | 116 | {% for i, q in enumerate(t['questions']) %} |
117 | - {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), show_ref=t['show_ref']) %} | |
117 | + {% module Template(templ[q['type']], i=i, q=q, md=md(q['ref']), debug=debug) %} | |
118 | 118 | {% end %} |
119 | 119 | |
120 | 120 | <div class="form-row"> |
121 | 121 | <div class="col-12"> |
122 | - <button type="button" class="btn btn-success btn-lg btn-block" data-toggle="modal" data-target="#confirmar" id="form-button-submit">Submeter teste</button> | |
122 | + <button type="button" class="btn btn-success btn-lg btn-block" data-toggle="modal" data-target="#confirmar" id="form-button-submit"> | |
123 | + Submeter teste | |
124 | + </button> | |
123 | 125 | </div> |
124 | 126 | </div> |
125 | 127 | </form> |
... | ... | @@ -138,11 +140,16 @@ |
138 | 140 | <div class="modal-body"> |
139 | 141 | O teste será enviado para classificação e já não poderá voltar atrás. |
140 | 142 | Antes de submeter, verifique se respondeu a todas as questões. |
141 | - Desactive as perguntas que não pretende classificar para evitar eventuais penalizações. | |
143 | + Desactive as perguntas que não pretende classificar para evitar | |
144 | + eventuais penalizações. | |
142 | 145 | </div> |
143 | 146 | <div class="modal-footer"> |
144 | - <button type="button" class="btn btn-danger btn-lg" data-dismiss="modal">Oops, NÃO!!!</button> | |
145 | - <button form="test" type="submit" class="btn btn-success btn-lg">Sim, submeter...</button> | |
147 | + <button type="button" class="btn btn-danger btn-lg" data-dismiss="modal"> | |
148 | + Oops, NÃO!!! | |
149 | + </button> | |
150 | + <button form="test" type="submit" class="btn btn-success btn-lg"> | |
151 | + Sim, submeter... | |
152 | + </button> | |
146 | 153 | </div> |
147 | 154 | </div> |
148 | 155 | </div> | ... | ... |
perguntations/test.py
... | ... | @@ -7,9 +7,8 @@ from datetime import datetime |
7 | 7 | import json |
8 | 8 | import logging |
9 | 9 | from math import nan |
10 | -from os import path | |
11 | 10 | |
12 | -# Logger configuration | |
11 | + | |
13 | 12 | logger = logging.getLogger(__name__) |
14 | 13 | |
15 | 14 | |
... | ... | @@ -30,17 +29,17 @@ class Test(dict): |
30 | 29 | ''' |
31 | 30 | |
32 | 31 | # ------------------------------------------------------------------------ |
33 | - def __init__(self, d): | |
32 | + def __init__(self, d: dict): | |
34 | 33 | super().__init__(d) |
35 | 34 | self['grade'] = nan |
36 | 35 | self['comment'] = '' |
37 | 36 | |
38 | 37 | # ------------------------------------------------------------------------ |
39 | - def start(self, student: dict) -> None: | |
38 | + def start(self, uid: str) -> None: | |
40 | 39 | ''' |
41 | - Write student id in the test and register start time | |
40 | + Register student id and start time in the test | |
42 | 41 | ''' |
43 | - self['student'] = student | |
42 | + self['student'] = uid | |
44 | 43 | self['start_time'] = datetime.now() |
45 | 44 | self['finish_time'] = None |
46 | 45 | self['state'] = 'ACTIVE' |
... | ... | @@ -57,14 +56,14 @@ class Test(dict): |
57 | 56 | self['questions'][ref].set_answer(ans) |
58 | 57 | |
59 | 58 | # ------------------------------------------------------------------------ |
60 | - def submit(self, answers_dict) -> None: | |
59 | + def submit(self, answers: dict) -> None: | |
61 | 60 | ''' |
62 | 61 | Given a dictionary ans={'ref': 'some answer'} updates the answers of |
63 | 62 | multiple questions in the test. |
64 | 63 | Only affects the questions referred in the dictionary. |
65 | 64 | ''' |
66 | 65 | self['finish_time'] = datetime.now() |
67 | - for ref, ans in answers_dict.items(): | |
66 | + for ref, ans in answers.items(): | |
68 | 67 | self['questions'][ref].set_answer(ans) |
69 | 68 | self['state'] = 'SUBMITTED' |
70 | 69 | |
... | ... | @@ -104,15 +103,11 @@ class Test(dict): |
104 | 103 | self['grade'] = 0.0 |
105 | 104 | |
106 | 105 | # ------------------------------------------------------------------------ |
107 | - def save_json(self, pathfile) -> None: | |
106 | + def save_json(self, filename: str) -> None: | |
108 | 107 | '''save test in JSON format''' |
109 | - with open(pathfile, 'w') as file: | |
108 | + with open(filename, 'w', encoding='utf-8') as file: | |
110 | 109 | json.dump(self, file, indent=2, default=str) # str for datetime |
111 | 110 | |
112 | 111 | # ------------------------------------------------------------------------ |
113 | 112 | def __str__(self) -> str: |
114 | 113 | return '\n'.join([f'{k}: {v}' for k,v in self.items()]) |
115 | - # return ('Test:\n' | |
116 | - # f' student: {self.get("student", "--")}\n' | |
117 | - # f' start_time: {self.get("start_time", "--")}\n' | |
118 | - # f' questions: {", ".join(q["ref"] for q in self["questions"])}\n') | ... | ... |
perguntations/testfactory.py
... | ... | @@ -3,20 +3,65 @@ TestFactory - generates tests for students |
3 | 3 | ''' |
4 | 4 | |
5 | 5 | # python standard library |
6 | +import asyncio | |
6 | 7 | from os import path |
7 | 8 | import random |
8 | 9 | import logging |
9 | -import re | |
10 | -from typing import Any, Dict | |
10 | + | |
11 | +# other libraries | |
12 | +import schema | |
11 | 13 | |
12 | 14 | # this project |
13 | -from perguntations.questions import QFactory, QuestionException | |
14 | -from perguntations.test import Test | |
15 | -from perguntations.tools import load_yaml | |
15 | +from .questions import QFactory, QuestionException, QDict | |
16 | +from .test import Test | |
17 | +from .tools import load_yaml | |
16 | 18 | |
17 | 19 | # Logger configuration |
18 | 20 | logger = logging.getLogger(__name__) |
19 | 21 | |
22 | +# --- test validation -------------------------------------------------------- | |
23 | +def check_answers_directory(ans: str) -> bool: | |
24 | + '''Checks is answers_dir exists and is writable''' | |
25 | + testfile = path.join(path.expanduser(ans), 'REMOVE-ME') | |
26 | + try: | |
27 | + with open(testfile, 'w', encoding='utf-8') as file: | |
28 | + file.write('You can safely remove this file.') | |
29 | + except OSError: | |
30 | + return False | |
31 | + return True | |
32 | + | |
33 | +def check_import_files(files: list) -> bool: | |
34 | + '''Checks if the question files exist''' | |
35 | + if not files: | |
36 | + return False | |
37 | + for file in files: | |
38 | + if not path.isfile(file): | |
39 | + return False | |
40 | + return True | |
41 | + | |
42 | +def normalize_question_list(questions: list) -> None: | |
43 | + '''convert question ref from string to list of string''' | |
44 | + for question in questions: | |
45 | + if isinstance(question['ref'], str): | |
46 | + question['ref'] = [question['ref']] | |
47 | + | |
48 | +test_schema = schema.Schema({ | |
49 | + 'ref': schema.Regex('^[a-zA-Z0-9_-]+$'), | |
50 | + 'database': schema.And(str, path.isfile), | |
51 | + 'answers_dir': schema.And(str, check_answers_directory), | |
52 | + 'title': str, | |
53 | + schema.Optional('duration'): int, | |
54 | + schema.Optional('autosubmit'): bool, | |
55 | + schema.Optional('autocorrect'): bool, | |
56 | + schema.Optional('show_points'): bool, | |
57 | + schema.Optional('scale'): schema.And([schema.Use(float)], | |
58 | + lambda s: len(s) == 2), | |
59 | + 'files': schema.And([str], check_import_files), | |
60 | + 'questions': [{ | |
61 | + 'ref': schema.Or(str, [str]), | |
62 | + schema.Optional('points'): float | |
63 | + }] | |
64 | + }, ignore_extra_keys=True) | |
20 | 65 | |
21 | 66 | # ============================================================================ |
22 | 67 | class TestFactoryException(Exception): |
... | ... | @@ -32,34 +77,30 @@ class TestFactory(dict): |
32 | 77 | ''' |
33 | 78 | |
34 | 79 | # ------------------------------------------------------------------------ |
35 | - def __init__(self, conf: Dict[str, Any]) -> None: | |
80 | + def __init__(self, conf) -> None: | |
36 | 81 | ''' |
37 | 82 | Loads configuration from yaml file, then overrides some configurations |
38 | 83 | using the conf argument. |
39 | 84 | Base questions are added to a pool of questions factories. |
40 | 85 | ''' |
41 | 86 | |
87 | + test_schema.validate(conf) | |
88 | + | |
42 | 89 | # --- set test defaults and then use given configuration |
43 | 90 | super().__init__({ # defaults |
44 | - 'title': '', | |
45 | 91 | 'show_points': True, |
46 | 92 | 'scale': None, |
47 | 93 | 'duration': 0, # 0=infinite |
48 | 94 | 'autosubmit': False, |
49 | 95 | 'autocorrect': True, |
50 | - 'debug': False, | |
51 | - 'show_ref': False, | |
52 | 96 | }) |
53 | 97 | self.update(conf) |
98 | + normalize_question_list(self['questions']) | |
54 | 99 | |
55 | 100 | # --- for review, we are done. no factories needed |
56 | - if self['review']: | |
57 | - logger.info('Review mode. No questions loaded. No factories.') | |
58 | - return | |
59 | - | |
60 | - # --- perform sanity checks and normalize the test questions | |
61 | - self.sanity_checks() | |
62 | - logger.info('Sanity checks PASSED.') | |
101 | + # if self['review']: FIXME | |
102 | + # logger.info('Review mode. No questions loaded. No factories.') | |
103 | + # return | |
63 | 104 | |
64 | 105 | # --- find refs of all questions used in the test |
65 | 106 | qrefs = {r for qq in self['questions'] for r in qq['ref']} |
... | ... | @@ -70,7 +111,7 @@ class TestFactory(dict): |
70 | 111 | self['question_factory'] = {} |
71 | 112 | |
72 | 113 | for file in self["files"]: |
73 | - fullpath = path.normpath(path.join(self["questions_dir"], file)) | |
114 | + fullpath = path.normpath(file) | |
74 | 115 | |
75 | 116 | logger.info('Loading "%s"...', fullpath) |
76 | 117 | questions = load_yaml(fullpath) # , default=[]) |
... | ... | @@ -81,32 +122,25 @@ class TestFactory(dict): |
81 | 122 | msg = f'Question {i} in {file} is not a dictionary' |
82 | 123 | raise TestFactoryException(msg) |
83 | 124 | |
84 | - # check if ref is missing, then set to '/path/file.yaml:3' | |
125 | + # check if ref is missing, then set to '//file.yaml:3' | |
85 | 126 | if 'ref' not in question: |
86 | 127 | question['ref'] = f'{file}:{i:04}' |
87 | 128 | logger.warning('Missing ref set to "%s"', question["ref"]) |
88 | 129 | |
89 | 130 | # check for duplicate refs |
90 | - if question['ref'] in self['question_factory']: | |
91 | - other = self['question_factory'][question['ref']] | |
131 | + qref = question['ref'] | |
132 | + if qref in self['question_factory']: | |
133 | + other = self['question_factory'][qref] | |
92 | 134 | otherfile = path.join(other.question['path'], |
93 | 135 | other.question['filename']) |
94 | - msg = (f'Duplicate reference "{question["ref"]}" in files ' | |
95 | - f'"{otherfile}" and "{fullpath}".') | |
136 | + msg = f'Duplicate "{qref}" in {otherfile} and {fullpath}' | |
96 | 137 | raise TestFactoryException(msg) |
97 | 138 | |
98 | 139 | # make factory only for the questions used in the test |
99 | - if question['ref'] in qrefs: | |
140 | + if qref in qrefs: | |
100 | 141 | question.update(zip(('path', 'filename', 'index'), |
101 | 142 | path.split(fullpath) + (i,))) |
102 | - if question['type'] == 'code' and 'server' not in question: | |
103 | - try: | |
104 | - question['server'] = self['jobe_server'] | |
105 | - except KeyError as exc: | |
106 | - msg = f'Missing JOBE server in "{question["ref"]}"' | |
107 | - raise TestFactoryException(msg) from exc | |
108 | - | |
109 | - self['question_factory'][question['ref']] = QFactory(question) | |
143 | + self['question_factory'][qref] = QFactory(QDict(question)) | |
110 | 144 | |
111 | 145 | qmissing = qrefs.difference(set(self['question_factory'].keys())) |
112 | 146 | if qmissing: |
... | ... | @@ -118,157 +152,124 @@ class TestFactory(dict): |
118 | 152 | |
119 | 153 | |
120 | 154 | # ------------------------------------------------------------------------ |
121 | - def check_test_ref(self) -> None: | |
122 | - '''Test must have a `ref`''' | |
123 | - if 'ref' not in self: | |
124 | - raise TestFactoryException('Missing "ref" in configuration!') | |
125 | - if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']): | |
126 | - raise TestFactoryException('Test "ref" can only contain the ' | |
127 | - 'characters a-zA-Z0-9_-') | |
128 | - | |
129 | - def check_missing_database(self) -> None: | |
130 | - '''Test must have a database''' | |
131 | - if 'database' not in self: | |
132 | - raise TestFactoryException('Missing "database" in configuration') | |
133 | - if not path.isfile(path.expanduser(self['database'])): | |
134 | - msg = f'Database "{self["database"]}" not found!' | |
135 | - raise TestFactoryException(msg) | |
136 | - | |
137 | - def check_missing_answers_directory(self) -> None: | |
138 | - '''Test must have a answers directory''' | |
139 | - if 'answers_dir' not in self: | |
140 | - msg = 'Missing "answers_dir" in configuration' | |
141 | - raise TestFactoryException(msg) | |
142 | - | |
143 | - def check_answers_directory_writable(self) -> None: | |
144 | - '''Answers directory must be writable''' | |
145 | - testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') | |
146 | - try: | |
147 | - with open(testfile, 'w') as file: | |
148 | - file.write('You can safely remove this file.') | |
149 | - except OSError as exc: | |
150 | - msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | |
151 | - raise TestFactoryException(msg) from exc | |
152 | - | |
153 | - def check_questions_directory(self) -> None: | |
154 | - '''Check if questions directory is missing or not accessible.''' | |
155 | - if 'questions_dir' not in self: | |
156 | - logger.warning('Missing "questions_dir". Using "%s"', | |
157 | - path.abspath(path.curdir)) | |
158 | - self['questions_dir'] = path.curdir | |
159 | - elif not path.isdir(path.expanduser(self['questions_dir'])): | |
160 | - raise TestFactoryException(f'Can\'t find questions directory ' | |
161 | - f'"{self["questions_dir"]}"') | |
162 | - | |
163 | - def check_import_files(self) -> None: | |
164 | - '''Check if there are files to import (with questions)''' | |
165 | - if 'files' not in self: | |
166 | - msg = ('Missing "files" in configuration with the list of ' | |
167 | - 'question files to import!') | |
168 | - raise TestFactoryException(msg) | |
169 | - | |
170 | - if isinstance(self['files'], str): | |
171 | - self['files'] = [self['files']] | |
172 | - | |
173 | - def check_question_list(self) -> None: | |
174 | - '''normalize question list''' | |
175 | - if 'questions' not in self: | |
176 | - raise TestFactoryException('Missing "questions" in configuration') | |
177 | - | |
178 | - for i, question in enumerate(self['questions']): | |
179 | - # normalize question to a dict and ref to a list of references | |
180 | - if isinstance(question, str): # e.g., - some_ref | |
181 | - question = {'ref': [question]} # becomes - ref: [some_ref] | |
182 | - elif isinstance(question, dict) and isinstance(question['ref'], str): | |
183 | - question['ref'] = [question['ref']] | |
184 | - elif isinstance(question, list): | |
185 | - question = {'ref': [str(a) for a in question]} | |
186 | - | |
187 | - self['questions'][i] = question | |
188 | - | |
189 | - def check_missing_title(self) -> None: | |
190 | - '''Warns if title is missing''' | |
191 | - if not self['title']: | |
192 | - logger.warning('Title is undefined!') | |
193 | - | |
194 | - def check_grade_scaling(self) -> None: | |
195 | - '''Just informs the scale limits''' | |
196 | - if 'scale_points' in self: | |
197 | - msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, ' | |
198 | - 'scale_max were replaced by "scale: [min, max]".') | |
199 | - logger.warning(msg) | |
200 | - self['scale'] = [self['scale_min'], self['scale_max']] | |
155 | + # def check_test_ref(self) -> None: | |
156 | + # '''Test must have a `ref`''' | |
157 | + # if 'ref' not in self: | |
158 | + # raise TestFactoryException('Missing "ref" in configuration!') | |
159 | + # if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']): | |
160 | + # raise TestFactoryException('Test "ref" can only contain the ' | |
161 | + # 'characters a-zA-Z0-9_-') | |
162 | + | |
163 | + # def check_missing_database(self) -> None: | |
164 | + # '''Test must have a database''' | |
165 | + # if 'database' not in self: | |
166 | + # raise TestFactoryException('Missing "database" in configuration') | |
167 | + # if not path.isfile(path.expanduser(self['database'])): | |
168 | + # msg = f'Database "{self["database"]}" not found!' | |
169 | + # raise TestFactoryException(msg) | |
170 | + | |
171 | + # def check_missing_answers_directory(self) -> None: | |
172 | + # '''Test must have a answers directory''' | |
173 | + # if 'answers_dir' not in self: | |
174 | + # msg = 'Missing "answers_dir" in configuration' | |
175 | + # raise TestFactoryException(msg) | |
176 | + | |
177 | + # def check_answers_directory_writable(self) -> None: | |
178 | + # '''Answers directory must be writable''' | |
179 | + # testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') | |
180 | + # try: | |
181 | + # with open(testfile, 'w', encoding='utf-8') as file: | |
182 | + # file.write('You can safely remove this file.') | |
183 | + # except OSError as exc: | |
184 | + # msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | |
185 | + # raise TestFactoryException(msg) from exc | |
186 | + | |
187 | + # def check_questions_directory(self) -> None: | |
188 | + # '''Check if questions directory is missing or not accessible.''' | |
189 | + # if 'questions_dir' not in self: | |
190 | + # logger.warning('Missing "questions_dir". Using "%s"', | |
191 | + # path.abspath(path.curdir)) | |
192 | + # self['questions_dir'] = path.curdir | |
193 | + # elif not path.isdir(path.expanduser(self['questions_dir'])): | |
194 | + # raise TestFactoryException(f'Can\'t find questions directory ' | |
195 | + # f'"{self["questions_dir"]}"') | |
196 | + | |
197 | + # def check_import_files(self) -> None: | |
198 | + # '''Check if there are files to import (with questions)''' | |
199 | + # if 'files' not in self: | |
200 | + # msg = ('Missing "files" in configuration with the list of ' | |
201 | + # 'question files to import!') | |
202 | + # raise TestFactoryException(msg) | |
203 | + | |
204 | + # if isinstance(self['files'], str): | |
205 | + # self['files'] = [self['files']] | |
206 | + | |
207 | + # def check_question_list(self) -> None: | |
208 | + # '''normalize question list''' | |
209 | + # if 'questions' not in self: | |
210 | + # raise TestFactoryException('Missing "questions" in configuration') | |
211 | + | |
212 | + # for i, question in enumerate(self['questions']): | |
213 | + # # normalize question to a dict and ref to a list of references | |
214 | + # if isinstance(question, str): # e.g., - some_ref | |
215 | + # logger.warning(f'Question "{question}" should be a dictionary') | |
216 | + # question = {'ref': [question]} # becomes - ref: [some_ref] | |
217 | + # elif isinstance(question, dict) and isinstance(question['ref'], str): | |
218 | + # question['ref'] = [question['ref']] | |
219 | + # elif isinstance(question, list): | |
220 | + # question = {'ref': [str(a) for a in question]} | |
221 | + | |
222 | + # self['questions'][i] = question | |
223 | + | |
224 | + # def check_missing_title(self) -> None: | |
225 | + # '''Warns if title is missing''' | |
226 | + # if not self['title']: | |
227 | + # logger.warning('Title is undefined!') | |
228 | + | |
229 | + # def check_grade_scaling(self) -> None: | |
230 | + # '''Just informs the scale limits''' | |
231 | + # if 'scale_points' in self: | |
232 | + # msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, ' | |
233 | + # 'scale_max were replaced by "scale: [min, max]".') | |
234 | + # logger.warning(msg) | |
235 | + # self['scale'] = [self['scale_min'], self['scale_max']] | |
201 | 236 | |
202 | 237 | |
203 | 238 | # ------------------------------------------------------------------------ |
204 | - def sanity_checks(self) -> None: | |
205 | - ''' | |
206 | - Checks for valid keys and sets default values. | |
207 | - Also checks if some files and directories exist | |
208 | - ''' | |
209 | - self.check_test_ref() | |
210 | - self.check_missing_database() | |
211 | - self.check_missing_answers_directory() | |
212 | - self.check_answers_directory_writable() | |
213 | - self.check_questions_directory() | |
214 | - self.check_import_files() | |
215 | - self.check_question_list() | |
216 | - self.check_missing_title() | |
217 | - self.check_grade_scaling() | |
239 | + # def sanity_checks(self) -> None: | |
240 | + # ''' | |
241 | + # Checks for valid keys and sets default values. | |
242 | + # Also checks if some files and directories exist | |
243 | + # ''' | |
244 | + # self.check_test_ref() | |
245 | + # self.check_missing_database() | |
246 | + # self.check_missing_answers_directory() | |
247 | + # self.check_answers_directory_writable() | |
248 | + # self.check_questions_directory() | |
249 | + # self.check_import_files() | |
250 | + # self.check_question_list() | |
251 | + # self.check_missing_title() | |
252 | + # self.check_grade_scaling() | |
218 | 253 | |
219 | 254 | # ------------------------------------------------------------------------ |
220 | 255 | def check_questions(self) -> None: |
221 | 256 | ''' |
222 | 257 | checks if questions can be correctly generated and corrected |
223 | 258 | ''' |
224 | - logger.info('Checking if questions can be generated and corrected...') | |
259 | + logger.info('Checking questions...') | |
260 | + # FIXME get_event_loop will be deprecated in python3.10 | |
261 | + loop = asyncio.get_event_loop() | |
225 | 262 | for i, (qref, qfact) in enumerate(self['question_factory'].items()): |
226 | 263 | try: |
227 | - question = qfact.generate() | |
264 | + question = loop.run_until_complete(qfact.gen_async()) | |
228 | 265 | except Exception as exc: |
229 | 266 | msg = f'Failed to generate "{qref}"' |
230 | 267 | raise TestFactoryException(msg) from exc |
231 | 268 | else: |
232 | 269 | logger.info('%4d. %s: Ok', i, qref) |
233 | - # logger.info(' generate Ok') | |
234 | - | |
235 | - if question['type'] in ('code', 'textarea'): | |
236 | - if 'tests_right' in question: | |
237 | - for tnum, right_answer in enumerate(question['tests_right']): | |
238 | - try: | |
239 | - question.set_answer(right_answer) | |
240 | - question.correct() | |
241 | - except Exception as exc: | |
242 | - msg = f'Failed to correct "{qref}"' | |
243 | - raise TestFactoryException(msg) from exc | |
244 | - | |
245 | - if question['grade'] == 1.0: | |
246 | - logger.info(' test %i Ok', tnum) | |
247 | - else: | |
248 | - logger.error(' TEST %i IS WRONG!!!', tnum) | |
249 | - elif 'tests_wrong' in question: | |
250 | - for tnum, wrong_answer in enumerate(question['tests_wrong']): | |
251 | - try: | |
252 | - question.set_answer(wrong_answer) | |
253 | - question.correct() | |
254 | - except Exception as exc: | |
255 | - msg = f'Failed to correct "{qref}"' | |
256 | - raise TestFactoryException(msg) from exc | |
257 | - | |
258 | - if question['grade'] < 1.0: | |
259 | - logger.info(' test %i Ok', tnum) | |
260 | - else: | |
261 | - logger.error(' TEST %i IS WRONG!!!', tnum) | |
262 | - else: | |
263 | - try: | |
264 | - question.set_answer('') | |
265 | - question.correct() | |
266 | - except Exception as exc: | |
267 | - msg = f'Failed to correct "{qref}"' | |
268 | - raise TestFactoryException(msg) from exc | |
269 | - else: | |
270 | - logger.info(' correct Ok but no tests to run') | |
271 | 270 | |
271 | + if question['type'] == 'textarea': | |
272 | + _runtests_textarea(qref, question) | |
272 | 273 | # ------------------------------------------------------------------------ |
273 | 274 | async def generate(self): |
274 | 275 | ''' |
... | ... | @@ -326,16 +327,51 @@ class TestFactory(dict): |
326 | 327 | logger.error('%s errors found!', nerr) |
327 | 328 | |
328 | 329 | # copy these from the test configuratoin to each test instance |
329 | - inherit = {'ref', 'title', 'database', 'answers_dir', | |
330 | - 'questions_dir', 'files', | |
331 | - 'duration', 'autosubmit', 'autocorrect', | |
332 | - 'scale', 'show_points', | |
333 | - 'show_ref', 'debug', } | |
334 | - # NOT INCLUDED: testfile, allow_all, review | |
330 | + inherit = ['ref', 'title', 'database', 'answers_dir', 'files', 'scale', | |
331 | + 'duration', 'autosubmit', 'autocorrect', 'show_points'] | |
335 | 332 | |
336 | 333 | return Test({'questions': questions, **{k:self[k] for k in inherit}}) |
337 | 334 | |
338 | 335 | # ------------------------------------------------------------------------ |
339 | 336 | def __repr__(self): |
340 | 337 | testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) |
341 | - return '{\n' + testsettings + '\n}' | |
338 | + return 'TestFactory({\n' + testsettings + '\n})' | |
339 | + | |
340 | +# ============================================================================ | |
341 | +def _runtests_textarea(qref, question): | |
342 | + ''' | |
343 | + Checks if correction script works and runs tests if available | |
344 | + ''' | |
345 | + try: | |
346 | + question.set_answer('') | |
347 | + question.correct() | |
348 | + except Exception as exc: | |
349 | + msg = f'Failed to correct "{qref}"' | |
350 | + raise TestFactoryException(msg) from exc | |
351 | + logger.info(' correction works') | |
352 | + | |
353 | + for tnum, right_answer in enumerate(question.get('tests_right', {})): | |
354 | + try: | |
355 | + question.set_answer(right_answer) | |
356 | + question.correct() | |
357 | + except Exception as exc: | |
358 | + msg = f'Failed to correct "{qref}"' | |
359 | + raise TestFactoryException(msg) from exc | |
360 | + | |
361 | + if question['grade'] == 1.0: | |
362 | + logger.info(' tests_right[%i] Ok', tnum) | |
363 | + else: | |
364 | + logger.error(' tests_right[%i] FAILED!!!', tnum) | |
365 | + | |
366 | + for tnum, wrong_answer in enumerate(question.get('tests_wrong', {})): | |
367 | + try: | |
368 | + question.set_answer(wrong_answer) | |
369 | + question.correct() | |
370 | + except Exception as exc: | |
371 | + msg = f'Failed to correct "{qref}"' | |
372 | + raise TestFactoryException(msg) from exc | |
373 | + | |
374 | + if question['grade'] < 1.0: | |
375 | + logger.info(' tests_wrong[%i] Ok', tnum) | |
376 | + else: | |
377 | + logger.error(' tests_wrong[%i] FAILED!!!', tnum) | ... | ... |
perguntations/tools.py
1 | 1 | ''' |
2 | -This module contains helper functions to: | |
3 | -- load yaml files and report errors | |
4 | -- run external programs (sync and async) | |
2 | +File: perguntations/tools.py | |
3 | +Description: Helper functions to load yaml files and run external programs. | |
5 | 4 | ''' |
6 | 5 | |
7 | 6 | |
... | ... | @@ -20,28 +19,11 @@ import yaml |
20 | 19 | logger = logging.getLogger(__name__) |
21 | 20 | |
22 | 21 | |
23 | -# --------------------------------------------------------------------------- | |
24 | -def load_yaml(filename: str, default: Any = None) -> Any: | |
25 | - '''load data from yaml file''' | |
26 | - | |
27 | - filename = path.expanduser(filename) | |
28 | - try: | |
29 | - file = open(filename, 'r', encoding='utf-8') | |
30 | - except Exception as exc: | |
31 | - logger.error(exc) | |
32 | - if default is not None: | |
33 | - return default | |
34 | - raise | |
35 | - | |
36 | - with file: | |
37 | - try: | |
38 | - return yaml.safe_load(file) | |
39 | - except yaml.YAMLError as exc: | |
40 | - logger.error(str(exc).replace('\n', ' ')) | |
41 | - if default is not None: | |
42 | - return default | |
43 | - raise | |
44 | - | |
22 | +# ---------------------------------------------------------------------------- | |
23 | +def load_yaml(filename: str) -> Any: | |
24 | + '''load yaml file or raise exception on error''' | |
25 | + with open(path.expanduser(filename), 'r', encoding='utf-8') as file: | |
26 | + return yaml.safe_load(file) | |
45 | 27 | |
46 | 28 | # --------------------------------------------------------------------------- |
47 | 29 | def run_script(script: str, |
... | ... | @@ -53,7 +35,7 @@ def run_script(script: str, |
53 | 35 | The script is run in another process but this function blocks waiting |
54 | 36 | for its termination. |
55 | 37 | ''' |
56 | - logger.info('run_script "%s"', script) | |
38 | + logger.debug('run_script "%s"', script) | |
57 | 39 | |
58 | 40 | output = None |
59 | 41 | script = path.expanduser(script) | ... | ... |
setup.py
... | ... | @@ -22,14 +22,15 @@ setup( |
22 | 22 | url="https://git.xdi.uevora.pt/mjsb/perguntations.git", |
23 | 23 | packages=find_packages(), |
24 | 24 | include_package_data=True, # install files from MANIFEST.in |
25 | - python_requires='>=3.7.*', | |
25 | + python_requires='>=3.8.*', | |
26 | 26 | install_requires=[ |
27 | - 'tornado>=6.0', | |
28 | - 'mistune', | |
27 | + 'bcrypt>=3.1', | |
28 | + 'mistune<2.0', | |
29 | 29 | 'pyyaml>=5.1', |
30 | 30 | 'pygments', |
31 | - 'sqlalchemy', | |
32 | - 'bcrypt>=3.1' | |
31 | + 'schema>=0.7.5', | |
32 | + 'sqlalchemy>=1.4', | |
33 | + 'tornado>=6.1', | |
33 | 34 | ], |
34 | 35 | entry_points={ |
35 | 36 | 'console_scripts': [ | ... | ... |
update.sh