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 | # BUGS | 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 | - grade gives internal server error?? | 11 | - grade gives internal server error?? |
21 | - reload do teste recomeça a contagem no inicio do tempo. | 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 | - a revisao do teste não mostra as imagens. | 17 | - a revisao do teste não mostra as imagens. |
34 | -- `Test.reset_answers()` unused. | 18 | +- Test.reset_answers() unused. |
35 | - teste nao esta a mostrar imagens de vez em quando.??? | 19 | - teste nao esta a mostrar imagens de vez em quando.??? |
36 | - show-ref nao esta a funcionar na correccao (pelo menos) | 20 | - show-ref nao esta a funcionar na correccao (pelo menos) |
37 | 21 | ||
38 | # TODO | 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 | - permitir remover alunos que estão online para poderem comecar de novo. | 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 | - wait for admin to start test. (students can be allowed earlier) | 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 | - servidor ntpd no x220 para configurar a data/hora dos portateis dell | 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 | - submissao fazer um post ajax? | 38 | - submissao fazer um post ajax? |
63 | - adicionar opcao para eliminar um teste em curso. | 39 | - adicionar opcao para eliminar um teste em curso. |
64 | - enviar resposta de cada pergunta individualmente. | 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 | - como refrescar a tabela de admin sem fazer reload da pagina? | 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 | - test: Cada pergunta respondida é logo submetida. | 46 | - test: Cada pergunta respondida é logo submetida. |
78 | - test: calculadora javascript. | 47 | - test: calculadora javascript. |
79 | - admin: histograma das notas. | 48 | - admin: histograma das notas. |
80 | - admin: mostrar as horas a que o teste terminou para os testes terminados. | 49 | - admin: mostrar as horas a que o teste terminou para os testes terminados. |
81 | - admin: histograma das notas. | 50 | - admin: histograma das notas. |
82 | - admin: mostrar teste gerado para aluno (tipo review). | 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 | - fazer renderer para imagens, com links /file?ref=xpto;name=zzz.jpg | 53 | - fazer renderer para imagens, com links /file?ref=xpto;name=zzz.jpg |
86 | - fazer renderer para linguagem assembly mips? | 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 | - Gerar pdf's com todos os testes no final (pdfkit). | 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 | - permitir varios testes, aluno escolhe qual o teste que quer fazer. | 65 | - permitir varios testes, aluno escolhe qual o teste que quer fazer. |
93 | - se ocorrer um erro na correcçao avisar aluno para contactar o professor. | 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 | - criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput | 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 | - fazer uma calculadora javascript e por no menu. surge como modal | 72 | - fazer uma calculadora javascript e por no menu. surge como modal |
107 | 73 | ||
108 | # FIXED | 74 | # FIXED |
109 | 75 | ||
110 | -- JOBE correct async | ||
111 | - testar as perguntas todas no início do teste como o aprendizations. | 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 | - internal server error quando em --review, download csv detalhado. | 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 | - mostrar unfocus e window area em /admin | 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 | - botao de autorizar desliga-se, fazer debounce. | 83 | - botao de autorizar desliga-se, fazer debounce. |
124 | - link na pagina com a nota para voltar ao principio. | 84 | - link na pagina com a nota para voltar ao principio. |
125 | - default logger config mostrar horas com segundos | 85 | - default logger config mostrar horas com segundos |
@@ -128,45 +88,33 @@ | @@ -128,45 +88,33 @@ | ||
128 | - servidor nao esta a lidar com eventos resize. | 88 | - servidor nao esta a lidar com eventos resize. |
129 | - sock.bind(sockaddr) OSError: [Errno 48] Address already in use | 89 | - sock.bind(sockaddr) OSError: [Errno 48] Address already in use |
130 | - dizer quanto desconta em cada pergunta de escolha multipla | 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 | - nao esta a usar points das perguntas | 93 | - nao esta a usar points das perguntas |
136 | - quando se clica no texto de uma opcao, salta para outro lado na pagina. | 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 | - fazer package para instalar perguntations com pip. | 96 | - fazer package para instalar perguntations com pip. |
140 | - pymips: nao pode executar syscalls do spim. | 97 | - pymips: nao pode executar syscalls do spim. |
141 | - exception sqlalchemy relacionada com threads. | 98 | - exception sqlalchemy relacionada com threads. |
142 | - acrescentar logger.conf que sirva de base. | 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 | - usar npm para instalar javascript | 102 | - usar npm para instalar javascript |
148 | - se aluno entrar com l12345 rebenta. numa funcao get_... ver imagem no ipad | 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 | - textarea com codemirror | 106 | - textarea com codemirror |
154 | - decorador para user 0, evita o "if uid==0" em muitas funcoes. | 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 | - review nao esta a funcionar | 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 | - a primeira coluna da tabela admin deveria estar sempre ordenada. | 114 | - a primeira coluna da tabela admin deveria estar sempre ordenada. |
166 | - abortar depois de testar todas as perguntas, caso haja algum erro. | 115 | - abortar depois de testar todas as perguntas, caso haja algum erro. |
167 | - imagens jpg/png nas perguntas. | 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 | - upgrade popper e fazer link. | 118 | - upgrade popper e fazer link. |
171 | - mover scripts js para head, com defer. ver todos os templates. | 119 | - mover scripts js para head, com defer. ver todos os templates. |
172 | - update fontawesome to 5. | 120 | - update fontawesome to 5. |
@@ -176,8 +124,7 @@ | @@ -176,8 +124,7 @@ | ||
176 | - md_to_html() nao usa o segundo argumento q. pode retirar-se dos templates? | 124 | - md_to_html() nao usa o segundo argumento q. pode retirar-se dos templates? |
177 | - config/logger.yaml ainda é do cherrypy... | 125 | - config/logger.yaml ainda é do cherrypy... |
178 | - uniformizar question.py com a de aprendizations... | 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 | - converter markdown para mistune. | 128 | - converter markdown para mistune. |
182 | - como alterar configuracao para mostrar logs de debug? | 129 | - como alterar configuracao para mostrar logs de debug? |
183 | - espaco no final das tabelas. | 130 | - espaco no final das tabelas. |
@@ -193,43 +140,31 @@ | @@ -193,43 +140,31 @@ | ||
193 | - text-numeric não está a gerar a pergunta. faltam templates? | 140 | - text-numeric não está a gerar a pergunta. faltam templates? |
194 | - testar perguntas warning/warn | 141 | - testar perguntas warning/warn |
195 | - qd user 0 faz logout rebenta. | 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 | - configurar pf em freebsd, port forward 80 -> 8080. documentacao | 145 | - configurar pf em freebsd, port forward 80 -> 8080. documentacao |
203 | - barras com notas em grade estão desalinhadas. | 146 | - barras com notas em grade estão desalinhadas. |
204 | - erros nos generators devem ser ERROR e não WARNING. | 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 | - se pergunta tiver 'type:' errado, rebenta. | 151 | - se pergunta tiver 'type:' errado, rebenta. |
212 | - se submeter um teste so com information, da divisao por zero. | 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 | - first login é INFO e não WARNING | 154 | - first login é INFO e não WARNING |
216 | - /review não mostra imagens porque precisa que teste esteja a decorrer... | 155 | - /review não mostra imagens porque precisa que teste esteja a decorrer... |
217 | - visualizar um teste ja realizado na página de administração | 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 | - permitir adicionar imagens nas perguntas. | 160 | - permitir adicionar imagens nas perguntas. |
224 | - detect_unfocus.js so funciona se estiver inline no html. porquê??? | 161 | - detect_unfocus.js so funciona se estiver inline no html. porquê??? |
225 | - inserir novo aluno /admin não fecha. | 162 | - inserir novo aluno /admin não fecha. |
226 | - se aluno desistir, ainda fica marcado como online | 163 | - se aluno desistir, ainda fica marcado como online |
227 | - give dá None em vez de 0.0 | 164 | - give dá None em vez de 0.0 |
228 | - debug mode: log levels not working | 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 | - sqlalchemy queixa-se de threads. | 168 | - sqlalchemy queixa-se de threads. |
234 | - SQLAlchemy em vez da classe database. | 169 | - SQLAlchemy em vez da classe database. |
235 | - replace sys.exit calls | 170 | - replace sys.exit calls |
@@ -238,21 +173,20 @@ | @@ -238,21 +173,20 @@ | ||
238 | - configuracao dos logs cherrypy para se darem bem com os outros | 173 | - configuracao dos logs cherrypy para se darem bem com os outros |
239 | - browser e ip usados gravado no test. | 174 | - browser e ip usados gravado no test. |
240 | - botões allow all/deny all. | 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 | - Não mostrar Professor nos activos em /admin | 179 | - Não mostrar Professor nos activos em /admin |
249 | - /admin mostrar actualizações automaticamente? | 180 | - /admin mostrar actualizações automaticamente? |
250 | - se no teste uma das "ref" nao existir nos ficheiros de perguntas, rebenta. | 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 | - script de correcção pode enviar dicionario yaml com grade e comentarios. ex: | 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,26 +11,27 @@ | ||
11 | 11 | ||
12 | ## Requirements | 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 | ```sh | 18 | ```sh |
17 | sudo apt install python3 python3-pip npm # Ubuntu | 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 | ```ini | 27 | ```ini |
25 | [global] | 28 | [global] |
26 | user=yes | 29 | user=yes |
27 | - | ||
28 | -[list] | ||
29 | -format=columns | ||
30 | ``` | 30 | ``` |
31 | 31 | ||
32 | This file is usually in `~/.config/pip/` in Linux and FreeBSD. In MacOS it's in | 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,19 +43,21 @@ Download and install: | ||
42 | git clone https://git.xdi.uevora.pt/mjsb/perguntations.git | 43 | git clone https://git.xdi.uevora.pt/mjsb/perguntations.git |
43 | cd perguntations | 44 | cd perguntations |
44 | npm install | 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 | ## Setup | 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 | - public server with static IP address and registered domain name; | 62 | - public server with static IP address and registered domain name; |
60 | - private server on a local network isolated from the internet. | 63 | - private server on a local network isolated from the internet. |
@@ -74,38 +77,35 @@ cd ~/.local/share/certs | @@ -74,38 +77,35 @@ cd ~/.local/share/certs | ||
74 | openssl req -x509 -newkey rsa:4096 -keyout privkey.pem -out cert.pem -days 365 -nodes | 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 | ## Running a demo | 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 | ```sh | 92 | ```sh |
92 | cd perguntations/demo | 93 | cd perguntations/demo |
93 | - | ||
94 | initdb students.csv # initialize from a CSV file | 94 | initdb students.csv # initialize from a CSV file |
95 | initdb --admin # only adds the administrator account | 95 | initdb --admin # only adds the administrator account |
96 | initdb --add 123 "Asterix Gaules" # add one student | 96 | initdb --add 123 "Asterix Gaules" # add one student |
97 | ``` | 97 | ``` |
98 | 98 | ||
99 | This will create or update a `students.db` file that contains a sqlite3 | 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 | ```sh | 110 | ```sh |
111 | mkdir ans # directory where the tests will be saved | 111 | mkdir ans # directory where the tests will be saved |
@@ -114,38 +114,36 @@ mkdir ans # directory where the tests will be saved | @@ -114,38 +114,36 @@ mkdir ans # directory where the tests will be saved | ||
114 | Start the server and run the `demo.yaml` test: | 114 | Start the server and run the `demo.yaml` test: |
115 | 115 | ||
116 | ```sh | 116 | ```sh |
117 | -perguntations demo.yaml # run demo test | 117 | +perguntations demo.yaml # run demo test |
118 | ``` | 118 | ``` |
119 | 119 | ||
120 | Several options are available, run `perguntations --help` for a list. | 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 | (administrator) and choose any password you want. The password is defined on | 124 | (administrator) and choose any password you want. The password is defined on |
125 | first login. | 125 | first login. |
126 | + | ||
126 | After logging in, you will be redirected to the administration page that shows | 127 | After logging in, you will be redirected to the administration page that shows |
127 | all the students and their current state. | 128 | all the students and their current state. |
128 | 129 | ||
129 | 1. Authorize students by clicking the checkboxes. | 130 | 1. Authorize students by clicking the checkboxes. |
130 | 2. Open a different browser (or exit administrator) at `http://127.0.0.1:8443/` | 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 | ## Running on lower ports | 139 | ## Running on lower ports |
139 | 140 | ||
140 | Ports 80 and 443 are reserved for the root user and and this software **should | 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 | ```.sh | 148 | ```.sh |
151 | iptables -t nat -I PREROUTING -i eth0 -p tcp --dport 443 -j REDIRECT --to-ports 8443 | 149 | iptables -t nat -I PREROUTING -i eth0 -p tcp --dport 443 -j REDIRECT --to-ports 8443 |
@@ -166,25 +164,29 @@ Explanation: | @@ -166,25 +164,29 @@ Explanation: | ||
166 | 164 | ||
167 | Edit `/etc/pf.conf`: | 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 | Start firewall with `sudo service pf start`. | 176 | Start firewall with `sudo service pf start`. |
177 | 177 | ||
178 | Optionally, to activate pf on boot, edit `rc.conf`: | 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 | ## Generating certificates with LetsEncript (FreeBSD) | 191 | ## Generating certificates with LetsEncript (FreeBSD) |
190 | 192 | ||
@@ -192,7 +194,7 @@ Generating certificates for a public server (FreeBSD) requires a registered | @@ -192,7 +194,7 @@ Generating certificates for a public server (FreeBSD) requires a registered | ||
192 | domain with fixed IP. | 194 | domain with fixed IP. |
193 | 195 | ||
194 | ```sh | 196 | ```sh |
195 | -sudo pkg install py27-certbot # FreeBSD | 197 | +sudo pkg install py38-certbot # FreeBSD 13 |
196 | sudo service pf stop # disable pf firewall (FreeBSD) | 198 | sudo service pf stop # disable pf firewall (FreeBSD) |
197 | sudo certbot certonly --standalone -d www.example.com | 199 | sudo certbot certonly --standalone -d www.example.com |
198 | sudo service pf start # enable pf firewall | 200 | sudo service pf start # enable pf firewall |
@@ -208,47 +210,52 @@ chmod 400 cert.pem privkey.pem | @@ -208,47 +210,52 @@ chmod 400 cert.pem privkey.pem | ||
208 | Certificate renewals can be done as follows: | 210 | Certificate renewals can be done as follows: |
209 | 211 | ||
210 | ```sh | 212 | ```sh |
211 | -sudo service pf stop # shutdown firewall | 213 | +sudo service pf stop # shutdown firewall |
212 | sudo certbot renew | 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 | ## Upgrading | 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 | ```sh | 229 | ```sh |
227 | -pip list --outdated # lists upgradable packages | 230 | +pip list --outdated --user # lists upgradable packages |
228 | pip install -U something # upgrade something | 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 | To upgrade perguntations and javascript libraries do: | 238 | To upgrade perguntations and javascript libraries do: |
232 | 239 | ||
233 | ```sh | 240 | ```sh |
234 | cd perguntations | 241 | cd perguntations |
235 | git pull # get latest version of perguntations | 242 | git pull # get latest version of perguntations |
236 | npm update # get latest versions of javascript libraries | 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 | ## Troubleshooting | 247 | ## Troubleshooting |
241 | 248 | ||
242 | - The server tries to run `python3` so this command must be accessible from | 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 | - If you are getting any `UnicodeEncodeError` type of errors that's because the | 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 | - debian: `sudo dpkg-reconfigure locales` and select your UTF-8 locales. | 259 | - debian: `sudo dpkg-reconfigure locales` and select your UTF-8 locales. |
253 | - FreeBSD: edit `~/.login_conf` to use UTF-8, for example: | 260 | - FreeBSD: edit `~/.login_conf` to use UTF-8, for example: |
254 | 261 |
demo/demo.yaml
@@ -14,9 +14,6 @@ database: students.db | @@ -14,9 +14,6 @@ database: students.db | ||
14 | # Directory where the submitted and corrected test are stored for later review. | 14 | # Directory where the submitted and corrected test are stored for later review. |
15 | answers_dir: ans | 15 | answers_dir: ans |
16 | 16 | ||
17 | -# Server used to compile & execute code | ||
18 | -jobe_server: 192.168.1.85 | ||
19 | - | ||
20 | # --- optional settings: ----------------------------------------------------- | 17 | # --- optional settings: ----------------------------------------------------- |
21 | 18 | ||
22 | # Title of this test, e.g. course name, year or test number | 19 | # Title of this test, e.g. course name, year or test number |
@@ -41,43 +38,46 @@ autocorrect: true | @@ -41,43 +38,46 @@ autocorrect: true | ||
41 | # (default: true) | 38 | # (default: true) |
42 | show_points: true | 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 | # (default: no scaling, just use question points) | 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 | files: | 49 | files: |
60 | - questions/questions-tutorial.yaml | 50 | - questions/questions-tutorial.yaml |
61 | 51 | ||
62 | # This is the list of questions that will make up the test. | 52 | # This is the list of questions that will make up the test. |
63 | # The order is preserved. | 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 | questions: | 59 | questions: |
66 | - ref: tut-test | 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 | - ref: tut-textarea | 71 | - ref: tut-textarea |
75 | points: 2.0 | 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 @@ | @@ -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,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,24 +20,20 @@ | ||
20 | database: students.db # base de dados previamente criada com initdb | 20 | database: students.db # base de dados previamente criada com initdb |
21 | answers_dir: ans # directório onde ficam os testes dos alunos | 21 | answers_dir: ans # directório onde ficam os testes dos alunos |
22 | 22 | ||
23 | - # opcional | 23 | + # opcionais |
24 | duration: 60 # duração da prova em minutos (default: inf) | 24 | duration: 60 # duração da prova em minutos (default: inf) |
25 | autosubmit: true # submissão automática (default: false) | 25 | autosubmit: true # submissão automática (default: false) |
26 | show_points: true # mostra cotação das perguntas (default: true) | 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 | # Ficheiros de perguntas a importar (relativamente a `questions_dir`) | 31 | # Ficheiros de perguntas a importar (relativamente a `questions_dir`) |
36 | files: | 32 | files: |
37 | - tabelas.yaml | 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 | # Especificação das perguntas do teste e respectivas cotações. | 39 | # Especificação das perguntas do teste e respectivas cotações. |
@@ -50,13 +46,10 @@ | @@ -50,13 +46,10 @@ | ||
50 | - ref: pergunta2 | 46 | - ref: pergunta2 |
51 | points: 2.0 | 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 | - ref: pergunta3 | 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 | - ref: [pergunta3a, pergunta3b] | 53 | - ref: [pergunta3a, pergunta3b] |
61 | points: 0.5 | 54 | points: 0.5 |
62 | 55 | ||
@@ -96,8 +89,7 @@ | @@ -96,8 +89,7 @@ | ||
96 | text: | | 89 | text: | |
97 | Quando o texto da pergunta tem várias linhas, dá jeito usar o símbolo | 90 | Quando o texto da pergunta tem várias linhas, dá jeito usar o símbolo |
98 | `|` de pipe, para indicar que tudo o que estiver indentado faz parte do | 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 | O texto das perguntas é escrito em `markdown` e suporta fórmulas em | 94 | O texto das perguntas é escrito em `markdown` e suporta fórmulas em |
103 | LaTeX. | 95 | LaTeX. |
@@ -105,8 +97,8 @@ | @@ -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 | De seguida mostram-se exemplos dos vários tipos de perguntas. | 102 | De seguida mostram-se exemplos dos vários tipos de perguntas. |
111 | 103 | ||
112 | # ---------------------------------------------------------------------------- | 104 | # ---------------------------------------------------------------------------- |
@@ -431,6 +423,10 @@ | @@ -431,6 +423,10 @@ | ||
431 | pode estar previamente preenchida como neste caso (use `answer: texto`). | 423 | pode estar previamente preenchida como neste caso (use `answer: texto`). |
432 | correct: correct/correct-question.py | 424 | correct: correct/correct-question.py |
433 | timeout: 5 | 425 | timeout: 5 |
426 | + tests_right: | ||
427 | + - 'red green blue' | ||
428 | + # tests_wrong: | ||
429 | + # - 'blue gray yellow' | ||
434 | 430 | ||
435 | # --------------------------------------------------------------------------- | 431 | # --------------------------------------------------------------------------- |
436 | - type: information | 432 | - type: information |
@@ -504,6 +500,7 @@ | @@ -504,6 +500,7 @@ | ||
504 | return 0; // comentario | 500 | return 0; // comentario |
505 | } | 501 | } |
506 | ``` | 502 | ``` |
503 | + | ||
507 | ``` | 504 | ``` |
508 | 505 | ||
509 | # --------------------------------------------------------------------------- | 506 | # --------------------------------------------------------------------------- |
@@ -586,7 +583,7 @@ | @@ -586,7 +583,7 @@ | ||
586 | # ---------------------------------------------------------------------------- | 583 | # ---------------------------------------------------------------------------- |
587 | - type: generator | 584 | - type: generator |
588 | ref: tut-generator | 585 | ref: tut-generator |
589 | - script: generators/generate-question.py | 586 | + script: generate-question.py |
590 | args: [1, 100] | 587 | args: [1, 100] |
591 | 588 | ||
592 | # ---------------------------------------------------------------------------- | 589 | # ---------------------------------------------------------------------------- |
mypy.ini
1 | [mypy] | 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 | ignore_missing_imports = True | 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,12 +2,12 @@ | ||
2 | "description": "Javascript libraries required to run the server", | 2 | "description": "Javascript libraries required to run the server", |
3 | "email": "mjsb@uevora.pt", | 3 | "email": "mjsb@uevora.pt", |
4 | "dependencies": { | 4 | "dependencies": { |
5 | - "@fortawesome/fontawesome-free": "^5.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 | "datatables": "^1.10", | 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 | # THE MIT License | 3 | # THE MIT License |
4 | # | 4 | # |
@@ -32,10 +32,10 @@ proof of submission and for review. | @@ -32,10 +32,10 @@ proof of submission and for review. | ||
32 | ''' | 32 | ''' |
33 | 33 | ||
34 | APP_NAME = 'perguntations' | 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 | __author__ = 'Miguel Barão' | 38 | __author__ = 'Miguel Barão' |
39 | -__copyright__ = 'Copyright 2020, Miguel Barão' | 39 | +__copyright__ = 'Copyright 2022, Miguel Barão' |
40 | __license__ = 'MIT license' | 40 | __license__ = 'MIT license' |
41 | __version__ = APP_VERSION | 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 | # python standard libraries | 7 | # python standard libraries |
7 | import asyncio | 8 | import asyncio |
8 | -from contextlib import contextmanager # `with` statement in db sessions | ||
9 | import csv | 9 | import csv |
10 | import io | 10 | import io |
11 | import json | 11 | import json |
12 | import logging | 12 | import logging |
13 | -from os import path | 13 | +import os |
14 | +from typing import Optional | ||
14 | 15 | ||
15 | # installed packages | 16 | # installed packages |
16 | import bcrypt | 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 | # this project | 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 | logger = logging.getLogger(__name__) | 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 | '''check password in executor''' | 34 | '''check password in executor''' |
40 | - try_pw = try_pw.encode('utf-8') | ||
41 | loop = asyncio.get_running_loop() | 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 | loop = asyncio.get_running_loop() | 41 | loop = asyncio.get_running_loop() |
49 | return await loop.run_in_executor(None, bcrypt.hashpw, | 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 | # main application | 50 | # main application |
56 | # ============================================================================ | 51 | # ============================================================================ |
57 | class App(): | 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 | else: | 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 | self._correct_tests() | 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 | return 'wrong_password' | 142 | return 'wrong_password' |
205 | 143 | ||
206 | # success | 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 | else: | 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 | '''student logout''' | 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 | Setup a factory for the test | 182 | Setup a factory for the test |
230 | ''' | 183 | ''' |
231 | 184 | ||
232 | # load configuration from yaml file | 185 | # load configuration from yaml file |
233 | - logger.info('Loading test configuration "%s".', conf["testfile"]) | ||
234 | try: | 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 | raise AppException(msg) from exc | 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 | logger.info('Running test factory...') | 195 | logger.info('Running test factory...') |
246 | try: | 196 | try: |
247 | - self.testfactory = TestFactory(testconf) | 197 | + self._testfactory = TestFactory(testconf) |
248 | except TestFactoryException as exc: | 198 | except TestFactoryException as exc: |
249 | - logger.critical(exc) | 199 | + logger.error(exc) |
250 | raise AppException('Failed to create test factory!') from exc | 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 | Handles test submission and correction. | 205 | Handles test submission and correction. |
304 | 206 | ||
305 | ans is a dictionary {question_index: answer, ...} with the answers for | 207 | ans is a dictionary {question_index: answer, ...} with the answers for |
306 | the complete test. For example: {0:'hello', 1:[1,2]} | 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 | # --- submit answers and correct test | 214 | # --- submit answers and correct test |
215 | + logger.info('"%s" submitted %d answers', uid, len(ans)) | ||
216 | + test = self._students[uid]['test'] | ||
311 | test.submit(ans) | 217 | test.submit(ans) |
312 | - logger.info('"%s" submitted %d answers.', uid, len(ans)) | ||
313 | 218 | ||
314 | if test['autocorrect']: | 219 | if test['autocorrect']: |
315 | await test.correct_async() | 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 | # --- save test in JSON format | 223 | # --- save test in JSON format |
319 | fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json' | 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 | test.save_json(fpath) | 226 | test.save_json(fpath) |
322 | - logger.info('"%s" saved JSON.', uid) | 227 | + logger.info('"%s" saved JSON', uid) |
323 | 228 | ||
324 | # --- insert test and questions into the database | 229 | # --- insert test and questions into the database |
325 | # only corrected questions are added | 230 | # only corrected questions are added |
@@ -348,13 +253,62 @@ class App(): | @@ -348,13 +253,62 @@ class App(): | ||
348 | for n, q in enumerate(test['questions']) | 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 | # def giveup_test(self, uid): | 314 | # def giveup_test(self, uid): |
@@ -366,7 +320,7 @@ class App(): | @@ -366,7 +320,7 @@ class App(): | ||
366 | # fields = (test['student']['number'], test['ref'], | 320 | # fields = (test['student']['number'], test['ref'], |
367 | # str(test['finish_time'])) | 321 | # str(test['finish_time'])) |
368 | # fname = '--'.join(fields) + '.json' | 322 | # fname = '--'.join(fields) + '.json' |
369 | - # fpath = path.join(test['answers_dir'], fname) | 323 | + # fpath = os.path.join(test['answers_dir'], fname) |
370 | # test.save_json(fpath) | 324 | # test.save_json(fpath) |
371 | 325 | ||
372 | # # insert test into database | 326 | # # insert test into database |
@@ -381,11 +335,11 @@ class App(): | @@ -381,11 +335,11 @@ class App(): | ||
381 | # state=test['state'], | 335 | # state=test['state'], |
382 | # comment='')) | 336 | # comment='')) |
383 | 337 | ||
384 | - # logger.info('"%s" gave up.', uid) | 338 | + # logger.info('"%s" gave up', uid) |
385 | # return test | 339 | # return test |
386 | 340 | ||
387 | # ------------------------------------------------------------------------ | 341 | # ------------------------------------------------------------------------ |
388 | - def event_test(self, uid, cmd, value): | 342 | + def register_event(self, uid, cmd, value): |
389 | '''handles browser events the occur during the test''' | 343 | '''handles browser events the occur during the test''' |
390 | if cmd == 'focus': | 344 | if cmd == 'focus': |
391 | if value: | 345 | if value: |
@@ -395,202 +349,200 @@ class App(): | @@ -395,202 +349,200 @@ class App(): | ||
395 | elif cmd == 'size': | 349 | elif cmd == 'size': |
396 | self._set_screen_area(uid, value) | 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 | if not tests: | 384 | if not tests: |
424 | logger.warning('Empty CSV: there are no tests!') | 385 | logger.warning('Empty CSV: there are no tests!') |
425 | return test_ref, '' | 386 | return test_ref, '' |
426 | 387 | ||
427 | - cols = ['Aluno', 'Início'] + list(qnums) | ||
428 | - | ||
429 | csvstr = io.StringIO() | 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 | return test_ref, csvstr.getvalue() | 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 | if not tests: | 413 | if not tests: |
448 | logger.warning('Empty CSV: there are no tests!') | 414 | logger.warning('Empty CSV: there are no tests!') |
449 | return test_ref, '' | 415 | return test_ref, '' |
450 | 416 | ||
451 | csvstr = io.StringIO() | 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 | return test_ref, csvstr.getvalue() | 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 | def get_json_filename_of_test(self, test_id): | 425 | def get_json_filename_of_test(self, test_id): |
467 | '''get JSON filename from database given the test_id''' | 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 | '''get grades of student for a given testid''' | 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 | '''allow a single student to login''' | 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 | '''deny a single student to login''' | 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 | logger.info('"%s" denied to login', uid) | 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 | '''allow all students to login''' | 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 | '''deny all students to login''' | 479 | '''deny all students to login''' |
538 | logger.info('Denying all students...') | 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 | try: | 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 | error_msg = f'Cannot read file {filename}' | 512 | error_msg = f'Cannot read file {filename}' |
548 | logger.critical(error_msg) | 513 | logger.critical(error_msg) |
549 | raise AppException(error_msg) from exc | 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 | def _focus_student(self, uid): | 530 | def _focus_student(self, uid): |
562 | '''set student in focus state''' | 531 | '''set student in focus state''' |
563 | - self.unfocus.discard(uid) | 532 | + self._students[uid]['unfocus'] = False |
564 | logger.info('"%s" focus', uid) | 533 | logger.info('"%s" focus', uid) |
565 | 534 | ||
535 | + # ------------------------------------------------------------------------ | ||
566 | def _unfocus_student(self, uid): | 536 | def _unfocus_student(self, uid): |
567 | '''set student in unfocus state''' | 537 | '''set student in unfocus state''' |
568 | - self.unfocus.add(uid) | 538 | + self._students[uid]['unfocus'] = True |
569 | logger.info('"%s" unfocus', uid) | 539 | logger.info('"%s" unfocus', uid) |
570 | 540 | ||
541 | + # ------------------------------------------------------------------------ | ||
571 | def _set_screen_area(self, uid, sizes): | 542 | def _set_screen_area(self, uid, sizes): |
572 | '''set current browser area as detected in resize event''' | 543 | '''set current browser area as detected in resize event''' |
573 | scr_y, scr_x, win_y, win_x = sizes | 544 | scr_y, scr_x, win_y, win_x = sizes |
574 | area = win_x * win_y / (scr_x * scr_y) * 100 | 545 | area = win_x * win_y / (scr_x * scr_y) * 100 |
575 | - self.area[uid] = area | 546 | + self._students[uid]['area'] = area |
576 | logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', | 547 | logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', |
577 | uid, area, win_x, win_y, scr_x, scr_y) | 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,17 +13,18 @@ from concurrent.futures import ThreadPoolExecutor | ||
13 | 13 | ||
14 | # installed packages | 14 | # installed packages |
15 | import bcrypt | 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 | from sqlalchemy.exc import IntegrityError | 18 | from sqlalchemy.exc import IntegrityError |
19 | 19 | ||
20 | # this project | 20 | # this project |
21 | -from perguntations.models import Base, Student | 21 | +from .models import Base, Student |
22 | 22 | ||
23 | 23 | ||
24 | # ============================================================================ | 24 | # ============================================================================ |
25 | def parse_commandline_arguments(): | 25 | def parse_commandline_arguments(): |
26 | '''Parse command line options''' | 26 | '''Parse command line options''' |
27 | + | ||
27 | parser = argparse.ArgumentParser( | 28 | parser = argparse.ArgumentParser( |
28 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, | 29 | formatter_class=argparse.ArgumentDefaultsHelpFormatter, |
29 | description='Insert new users into a database. Users can be imported ' | 30 | description='Insert new users into a database. Users can be imported ' |
@@ -104,7 +105,7 @@ def get_students_from_csv(filename): | @@ -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 | '''replace password by hash for a single student''' | 109 | '''replace password by hash for a single student''' |
109 | print('.', end='', flush=True) | 110 | print('.', end='', flush=True) |
110 | if password is None: | 111 | if password is None: |
@@ -115,12 +116,13 @@ def hashpw(student, password=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 | '''insert list of students into the database''' | 120 | '''insert list of students into the database''' |
120 | try: | 121 | try: |
121 | session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) | 122 | session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) |
122 | for s in students]) | 123 | for s in students]) |
123 | session.commit() | 124 | session.commit() |
125 | + | ||
124 | except IntegrityError: | 126 | except IntegrityError: |
125 | print('!!! Integrity error. Users already in database. Aborted !!!\n') | 127 | print('!!! Integrity error. Users already in database. Aborted !!!\n') |
126 | session.rollback() | 128 | session.rollback() |
@@ -129,11 +131,12 @@ def insert_students_into_db(session, students): | @@ -129,11 +131,12 @@ def insert_students_into_db(session, students): | ||
129 | # ============================================================================ | 131 | # ============================================================================ |
130 | def show_students_in_database(session, verbose=False): | 132 | def show_students_in_database(session, verbose=False): |
131 | '''get students from database''' | 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 | print('Registered users:') | 138 | print('Registered users:') |
136 | - if total_users == 0: | 139 | + if total == 0: |
137 | print(' -- none --') | 140 | print(' -- none --') |
138 | else: | 141 | else: |
139 | users.sort(key=lambda u: f'{u.id:>12}') # sort by number | 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,13 +145,13 @@ def show_students_in_database(session, verbose=False): | ||
142 | print(f'{user.id:>12} {user.name}') | 145 | print(f'{user.id:>12} {user.name}') |
143 | else: | 146 | else: |
144 | print(f'{users[0].id:>12} {users[0].name}') | 147 | print(f'{users[0].id:>12} {users[0].name}') |
145 | - if total_users > 1: | 148 | + if total > 1: |
146 | print(f'{users[1].id:>12} {users[1].name}') | 149 | print(f'{users[1].id:>12} {users[1].name}') |
147 | - if total_users > 3: | 150 | + if total > 3: |
148 | print(' | |') | 151 | print(' | |') |
149 | - if total_users > 2: | 152 | + if total > 2: |
150 | print(f'{users[-1].id:>12} {users[-1].name}') | 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,39 +161,45 @@ def main(): | ||
158 | args = parse_commandline_arguments() | 161 | args = parse_commandline_arguments() |
159 | 162 | ||
160 | # --- database | 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 | Base.metadata.create_all(engine) # Criates schema if needed | 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 | # --- make list of students to insert | 169 | # --- make list of students to insert |
168 | - new_students = [] | 170 | + students = [] |
169 | 171 | ||
170 | if args.admin: | 172 | if args.admin: |
171 | print('Adding user: 0, Admin.') | 173 | print('Adding user: 0, Admin.') |
172 | - new_students.append({'uid': '0', 'name': 'Admin'}) | 174 | + students.append({'uid': '0', 'name': 'Admin'}) |
173 | 175 | ||
174 | for csvfile in args.csvfile: | 176 | for csvfile in args.csvfile: |
175 | print('Adding users from:', csvfile) | 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 | if args.add: | 184 | if args.add: |
179 | for uid, name in args.add: | 185 | for uid, name in args.add: |
180 | print(f'Adding user: {uid}, {name}.') | 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 | # --- insert new students | 189 | # --- insert new students |
184 | - if new_students: | 190 | + if students: |
185 | print('Generating password hashes', end='') | 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 | # --- update all students | 197 | # --- update all students |
192 | if args.update_all: | 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 | print(f'Updating password of {len(all_students)} users', end='') | 203 | print(f'Updating password of {len(all_students)} users', end='') |
195 | for student in all_students: | 204 | for student in all_students: |
196 | password = (args.pw or student.id).encode('utf-8') | 205 | password = (args.pw or student.id).encode('utf-8') |
@@ -203,9 +212,12 @@ def main(): | @@ -203,9 +212,12 @@ def main(): | ||
203 | else: | 212 | else: |
204 | for student_id in args.update: | 213 | for student_id in args.update: |
205 | print(f'Updating password of {student_id}') | 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 | session.commit() | 221 | session.commit() |
210 | 222 | ||
211 | show_students_in_database(session, args.verbose) | 223 | show_students_in_database(session, args.verbose) |
perguntations/main.py
1 | #!/usr/bin/env python3 | 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,20 +10,17 @@ import argparse | ||
10 | import logging | 10 | import logging |
11 | import logging.config | 11 | import logging.config |
12 | import os | 12 | import os |
13 | -from os import environ, path | ||
14 | import ssl | 13 | import ssl |
15 | import sys | 14 | import sys |
16 | -# from typing import Any, Dict | ||
17 | 15 | ||
18 | # this project | 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 | Get command line arguments | 25 | Get command line arguments |
29 | ''' | 26 | ''' |
@@ -33,7 +30,6 @@ def parse_cmdline_arguments(): | @@ -33,7 +30,6 @@ def parse_cmdline_arguments(): | ||
33 | 'included with this software before running the server.') | 30 | 'included with this software before running the server.') |
34 | parser.add_argument('testfile', | 31 | parser.add_argument('testfile', |
35 | type=str, | 32 | type=str, |
36 | - # nargs='+', # TODO | ||
37 | help='tests in YAML format') | 33 | help='tests in YAML format') |
38 | parser.add_argument('--allow-all', | 34 | parser.add_argument('--allow-all', |
39 | action='store_true', | 35 | action='store_true', |
@@ -44,9 +40,6 @@ def parse_cmdline_arguments(): | @@ -44,9 +40,6 @@ def parse_cmdline_arguments(): | ||
44 | parser.add_argument('--debug', | 40 | parser.add_argument('--debug', |
45 | action='store_true', | 41 | action='store_true', |
46 | help='Enable debug messages') | 42 | help='Enable debug messages') |
47 | - parser.add_argument('--show-ref', | ||
48 | - action='store_true', | ||
49 | - help='Show question references') | ||
50 | parser.add_argument('--review', | 43 | parser.add_argument('--review', |
51 | action='store_true', | 44 | action='store_true', |
52 | help='Review mode: doesn\'t generate test') | 45 | help='Review mode: doesn\'t generate test') |
@@ -63,56 +56,50 @@ def parse_cmdline_arguments(): | @@ -63,56 +56,50 @@ def parse_cmdline_arguments(): | ||
63 | help='Show version information and exit') | 56 | help='Show version information and exit') |
64 | return parser.parse_args() | 57 | return parser.parse_args() |
65 | 58 | ||
66 | - | ||
67 | # ---------------------------------------------------------------------------- | 59 | # ---------------------------------------------------------------------------- |
68 | def get_logger_config(debug=False) -> dict: | 60 | def get_logger_config(debug=False) -> dict: |
69 | ''' | 61 | ''' |
70 | Load logger configuration from ~/.config directory if exists, | 62 | Load logger configuration from ~/.config directory if exists, |
71 | otherwise set default paramenters. | 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 | if debug: | 73 | if debug: |
74 | - filename = 'logger-debug.yaml' | ||
75 | level = 'DEBUG' | 74 | level = 'DEBUG' |
75 | + fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s' | ||
76 | + dateformat = '' | ||
76 | else: | 77 | else: |
77 | - filename = 'logger.yaml' | ||
78 | level = 'INFO' | 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 | def main() -> None: | 104 | def main() -> None: |
118 | ''' | 105 | ''' |
@@ -122,15 +109,16 @@ def main() -> None: | @@ -122,15 +109,16 @@ def main() -> None: | ||
122 | 109 | ||
123 | # --- Setup logging ------------------------------------------------------ | 110 | # --- Setup logging ------------------------------------------------------ |
124 | logging.config.dictConfig(get_logger_config(args.debug)) | 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 | # --- start application -------------------------------------------------- | 116 | # --- start application -------------------------------------------------- |
128 | config = { | 117 | config = { |
129 | 'testfile': args.testfile, | 118 | 'testfile': args.testfile, |
130 | - 'debug': args.debug, | ||
131 | 'allow_all': args.allow_all, | 119 | 'allow_all': args.allow_all, |
132 | 'allow_list': args.allow_list, | 120 | 'allow_list': args.allow_list, |
133 | - 'show_ref': args.show_ref, | 121 | + 'debug': args.debug, |
134 | 'review': args.review, | 122 | 'review': args.review, |
135 | 'correct': args.correct, | 123 | 'correct': args.correct, |
136 | } | 124 | } |
@@ -138,24 +126,24 @@ def main() -> None: | @@ -138,24 +126,24 @@ def main() -> None: | ||
138 | try: | 126 | try: |
139 | app = App(config) | 127 | app = App(config) |
140 | except AppException: | 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 | # --- get SSL certificates ----------------------------------------------- | 132 | # --- get SSL certificates ----------------------------------------------- |
145 | if 'XDG_DATA_HOME' in os.environ: | 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 | else: | 135 | else: |
148 | - certs_dir = path.expanduser('~/.local/share/certs') | 136 | + certs_dir = os.path.expanduser('~/.local/share/certs') |
149 | 137 | ||
150 | ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) | 138 | ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) |
151 | try: | 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 | except FileNotFoundError: | 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 | run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug) | 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 | SQLAlchemy ORM | 3 | SQLAlchemy ORM |
3 | - | ||
4 | -The classes below correspond to database tables | ||
5 | ''' | 4 | ''' |
6 | 5 | ||
6 | +from typing import Any | ||
7 | 7 | ||
8 | from sqlalchemy import Column, ForeignKey, Integer, Float, String | 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,11 +25,11 @@ class Student(Base): | ||
26 | # --- | 25 | # --- |
27 | tests = relationship('Test', back_populates='student') | 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,18 +51,18 @@ class Test(Base): | ||
52 | student = relationship('Student', back_populates='tests') | 51 | student = relationship('Student', back_populates='tests') |
53 | questions = relationship('Question', back_populates='test') | 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,13 +81,13 @@ class Question(Base): | ||
82 | # --- | 81 | # --- |
83 | test = relationship('Test', back_populates='questions') | 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,9 +137,9 @@ class HighlightRenderer(mistune.Renderer): | ||
137 | return '<table class="table table-sm"><thead class="thead-light">' \ | 137 | return '<table class="table table-sm"><thead class="thead-light">' \ |
138 | + header + '</thead><tbody>' + body + '</tbody></table>' | 138 | + header + '</thead><tbody>' + body + '</tbody></table>' |
139 | 139 | ||
140 | - def image(self, src, title, alt): | 140 | + def image(self, src, title, text): |
141 | '''render image''' | 141 | '''render image''' |
142 | - alt = mistune.escape(alt, quote=True) | 142 | + alt = mistune.escape(text, quote=True) |
143 | if title is not None: | 143 | if title is not None: |
144 | if title: # not empty string, show as caption | 144 | if title: # not empty string, show as caption |
145 | title = mistune.escape(title, quote=True) | 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 | # python standard library | 7 | # python standard library |
7 | -import asyncio | ||
8 | from datetime import datetime | 8 | from datetime import datetime |
9 | import logging | 9 | import logging |
10 | -from os import path | 10 | +import os |
11 | import random | 11 | import random |
12 | import re | 12 | import re |
13 | from typing import Any, Dict, NewType | 13 | from typing import Any, Dict, NewType |
14 | import uuid | 14 | import uuid |
15 | 15 | ||
16 | - | ||
17 | -# from urllib.error import HTTPError | ||
18 | -# import json | ||
19 | -# import http.client | ||
20 | - | ||
21 | - | ||
22 | # this project | 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 | # setup logger for this module | 19 | # setup logger for this module |
26 | logger = logging.getLogger(__name__) | 20 | logger = logging.getLogger(__name__) |
27 | 21 | ||
28 | - | ||
29 | QDict = NewType('QDict', Dict[str, Any]) | 22 | QDict = NewType('QDict', Dict[str, Any]) |
30 | 23 | ||
31 | 24 | ||
32 | - | ||
33 | - | ||
34 | class QuestionException(Exception): | 25 | class QuestionException(Exception): |
35 | '''Exceptions raised in this module''' | 26 | '''Exceptions raised in this module''' |
36 | 27 | ||
@@ -45,8 +36,6 @@ class Question(dict): | @@ -45,8 +36,6 @@ class Question(dict): | ||
45 | for each student. | 36 | for each student. |
46 | Instances can shuffle options or automatically generate questions. | 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 | def gen(self) -> None: | 40 | def gen(self) -> None: |
52 | ''' | 41 | ''' |
@@ -96,9 +85,6 @@ class QuestionRadio(Question): | @@ -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 | def gen(self) -> None: | 88 | def gen(self) -> None: |
103 | ''' | 89 | ''' |
104 | Sets defaults, performs checks and generates the actual question | 90 | Sets defaults, performs checks and generates the actual question |
@@ -128,8 +114,7 @@ class QuestionRadio(Question): | @@ -128,8 +114,7 @@ class QuestionRadio(Question): | ||
128 | # e.g. correct: 2 --> correct: [0,0,1,0,0] | 114 | # e.g. correct: 2 --> correct: [0,0,1,0,0] |
129 | if isinstance(self['correct'], int): | 115 | if isinstance(self['correct'], int): |
130 | if not 0 <= self['correct'] < nopts: | 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 | logger.error(msg) | 118 | logger.error(msg) |
134 | raise QuestionException(msg) | 119 | raise QuestionException(msg) |
135 | 120 | ||
@@ -139,8 +124,7 @@ class QuestionRadio(Question): | @@ -139,8 +124,7 @@ class QuestionRadio(Question): | ||
139 | elif isinstance(self['correct'], list): | 124 | elif isinstance(self['correct'], list): |
140 | # must match number of options | 125 | # must match number of options |
141 | if len(self['correct']) != nopts: | 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 | logger.error(msg) | 128 | logger.error(msg) |
145 | raise QuestionException(msg) | 129 | raise QuestionException(msg) |
146 | 130 | ||
@@ -148,23 +132,20 @@ class QuestionRadio(Question): | @@ -148,23 +132,20 @@ class QuestionRadio(Question): | ||
148 | try: | 132 | try: |
149 | self['correct'] = [float(x) for x in self['correct']] | 133 | self['correct'] = [float(x) for x in self['correct']] |
150 | except (ValueError, TypeError) as exc: | 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 | logger.error(msg) | 136 | logger.error(msg) |
154 | raise QuestionException(msg) from exc | 137 | raise QuestionException(msg) from exc |
155 | 138 | ||
156 | # check grade boundaries | 139 | # check grade boundaries |
157 | if self['discount'] and not all(0.0 <= x <= 1.0 | 140 | if self['discount'] and not all(0.0 <= x <= 1.0 |
158 | for x in self['correct']): | 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 | logger.error(msg) | 143 | logger.error(msg) |
162 | raise QuestionException(msg) | 144 | raise QuestionException(msg) |
163 | 145 | ||
164 | # at least one correct option | 146 | # at least one correct option |
165 | if all(x < 1.0 for x in self['correct']): | 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 | logger.error(msg) | 149 | logger.error(msg) |
169 | raise QuestionException(msg) | 150 | raise QuestionException(msg) |
170 | 151 | ||
@@ -231,9 +212,6 @@ class QuestionCheckbox(Question): | @@ -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 | def gen(self) -> None: | 215 | def gen(self) -> None: |
238 | super().gen() | 216 | super().gen() |
239 | 217 | ||
@@ -288,19 +266,6 @@ class QuestionCheckbox(Question): | @@ -288,19 +266,6 @@ class QuestionCheckbox(Question): | ||
288 | f'Please fix "{self["ref"]}" in "{self["path"]}"') | 266 | f'Please fix "{self["ref"]}" in "{self["path"]}"') |
289 | logger.error(msg) | 267 | logger.error(msg) |
290 | raise QuestionException(msg) | 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 | # if an option is a list of (right, wrong), pick one | 270 | # if an option is a list of (right, wrong), pick one |
306 | options = [] | 271 | options = [] |
@@ -356,9 +321,6 @@ class QuestionText(Question): | @@ -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 | def gen(self) -> None: | 324 | def gen(self) -> None: |
363 | super().gen() | 325 | super().gen() |
364 | self.set_defaults(QDict({ | 326 | self.set_defaults(QDict({ |
@@ -389,12 +351,13 @@ class QuestionText(Question): | @@ -389,12 +351,13 @@ class QuestionText(Question): | ||
389 | def transform(self, ans): | 351 | def transform(self, ans): |
390 | '''apply optional filters to the answer''' | 352 | '''apply optional filters to the answer''' |
391 | 353 | ||
354 | + # apply transformations in sequence | ||
392 | for transform in self['transform']: | 355 | for transform in self['transform']: |
393 | if transform == 'remove_space': # removes all spaces | 356 | if transform == 'remove_space': # removes all spaces |
394 | ans = ans.replace(' ', '') | 357 | ans = ans.replace(' ', '') |
395 | elif transform == 'trim': # removes spaces around | 358 | elif transform == 'trim': # removes spaces around |
396 | ans = ans.strip() | 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 | ans = re.sub(r'\s+', ' ', ans.strip()) | 361 | ans = re.sub(r'\s+', ' ', ans.strip()) |
399 | elif transform == 'lower': # convert to lowercase | 362 | elif transform == 'lower': # convert to lowercase |
400 | ans = ans.lower() | 363 | ans = ans.lower() |
@@ -410,7 +373,7 @@ class QuestionText(Question): | @@ -410,7 +373,7 @@ class QuestionText(Question): | ||
410 | super().correct() | 373 | super().correct() |
411 | 374 | ||
412 | if self['answer'] is not None: | 375 | if self['answer'] is not None: |
413 | - answer = self.transform(self['answer']) # apply transformations | 376 | + answer = self.transform(self['answer']) |
414 | self['grade'] = 1.0 if answer in self['correct'] else 0.0 | 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,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 | def gen(self) -> None: | 393 | def gen(self) -> None: |
434 | super().gen() | 394 | super().gen() |
435 | 395 | ||
@@ -442,14 +402,6 @@ class QuestionTextRegex(Question): | @@ -442,14 +402,6 @@ class QuestionTextRegex(Question): | ||
442 | if not isinstance(self['correct'], list): | 402 | if not isinstance(self['correct'], list): |
443 | self['correct'] = [self['correct']] | 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 | def correct(self) -> None: | 406 | def correct(self) -> None: |
455 | super().correct() | 407 | super().correct() |
@@ -464,15 +416,6 @@ class QuestionTextRegex(Question): | @@ -464,15 +416,6 @@ class QuestionTextRegex(Question): | ||
464 | regex, self['answer']) | 416 | regex, self['answer']) |
465 | self['grade'] = 0.0 | 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 | class QuestionNumericInterval(Question): | 420 | class QuestionNumericInterval(Question): |
478 | '''An instance of QuestionTextNumeric will always have the keys: | 421 | '''An instance of QuestionTextNumeric will always have the keys: |
@@ -484,9 +427,6 @@ class QuestionNumericInterval(Question): | @@ -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 | def gen(self) -> None: | 430 | def gen(self) -> None: |
491 | super().gen() | 431 | super().gen() |
492 | 432 | ||
@@ -548,9 +488,6 @@ class QuestionTextArea(Question): | @@ -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 | def gen(self) -> None: | 491 | def gen(self) -> None: |
555 | super().gen() | 492 | super().gen() |
556 | 493 | ||
@@ -561,7 +498,7 @@ class QuestionTextArea(Question): | @@ -561,7 +498,7 @@ class QuestionTextArea(Question): | ||
561 | 'args': [] | 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 | def correct(self) -> None: | 504 | def correct(self) -> None: |
@@ -625,129 +562,6 @@ class QuestionTextArea(Question): | @@ -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 | class QuestionInformation(Question): | 565 | class QuestionInformation(Question): |
752 | ''' | 566 | ''' |
753 | Not really a question, just an information panel. | 567 | Not really a question, just an information panel. |
@@ -755,9 +569,6 @@ class QuestionInformation(Question): | @@ -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 | def gen(self) -> None: | 572 | def gen(self) -> None: |
762 | super().gen() | 573 | super().gen() |
763 | self.set_defaults(QDict({ | 574 | self.set_defaults(QDict({ |
@@ -770,7 +581,6 @@ class QuestionInformation(Question): | @@ -770,7 +581,6 @@ class QuestionInformation(Question): | ||
770 | self['grade'] = 1.0 # always "correct" but points should be zero! | 581 | self['grade'] = 1.0 # always "correct" but points should be zero! |
771 | 582 | ||
772 | 583 | ||
773 | - | ||
774 | # ============================================================================ | 584 | # ============================================================================ |
775 | def question_from(qdict: QDict) -> Question: | 585 | def question_from(qdict: QDict) -> Question: |
776 | ''' | 586 | ''' |
@@ -783,7 +593,6 @@ def question_from(qdict: QDict) -> Question: | @@ -783,7 +593,6 @@ def question_from(qdict: QDict) -> Question: | ||
783 | 'text-regex': QuestionTextRegex, | 593 | 'text-regex': QuestionTextRegex, |
784 | 'numeric-interval': QuestionNumericInterval, | 594 | 'numeric-interval': QuestionNumericInterval, |
785 | 'textarea': QuestionTextArea, | 595 | 'textarea': QuestionTextArea, |
786 | - # 'code': QuestionCode, | ||
787 | # -- informative panels -- | 596 | # -- informative panels -- |
788 | 'information': QuestionInformation, | 597 | 'information': QuestionInformation, |
789 | 'success': QuestionInformation, | 598 | 'success': QuestionInformation, |
@@ -856,17 +665,17 @@ class QFactory(): | @@ -856,17 +665,17 @@ class QFactory(): | ||
856 | logger.debug('generating %s...', self.qdict["ref"]) | 665 | logger.debug('generating %s...', self.qdict["ref"]) |
857 | # Shallow copy so that script generated questions will not replace | 666 | # Shallow copy so that script generated questions will not replace |
858 | # the original generators | 667 | # the original generators |
859 | - qdict = self.qdict.copy() | 668 | + qdict = QDict(self.qdict.copy()) |
860 | qdict['qid'] = str(uuid.uuid4()) # unique for each question | 669 | qdict['qid'] = str(uuid.uuid4()) # unique for each question |
861 | 670 | ||
862 | # If question is of generator type, an external program will be run | 671 | # If question is of generator type, an external program will be run |
863 | # which will print a valid question in yaml format to stdout. This | 672 | # which will print a valid question in yaml format to stdout. This |
864 | # output is then yaml parsed into a dictionary `q`. | 673 | # output is then yaml parsed into a dictionary `q`. |
865 | if qdict['type'] == 'generator': | 674 | if qdict['type'] == 'generator': |
866 | - logger.debug(' \\_ Running "%s".', qdict['script']) | 675 | + logger.debug(' \\_ Running "%s"', qdict['script']) |
867 | qdict.setdefault('args', []) | 676 | qdict.setdefault('args', []) |
868 | qdict.setdefault('stdin', '') | 677 | qdict.setdefault('stdin', '') |
869 | - script = path.join(qdict['path'], qdict['script']) | 678 | + script = os.path.join(qdict['path'], qdict['script']) |
870 | out = await run_script_async(script=script, | 679 | out = await run_script_async(script=script, |
871 | args=qdict['args'], | 680 | args=qdict['args'], |
872 | stdin=qdict['stdin']) | 681 | stdin=qdict['stdin']) |
@@ -875,8 +684,3 @@ class QFactory(): | @@ -875,8 +684,3 @@ class QFactory(): | ||
875 | question = question_from(qdict) # returns a Question instance | 684 | question = question_from(qdict) # returns a Question instance |
876 | question.gen() | 685 | question.gen() |
877 | return question | 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,23 +10,27 @@ import asyncio | ||
10 | import base64 | 10 | import base64 |
11 | import functools | 11 | import functools |
12 | import json | 12 | import json |
13 | -import logging.config | 13 | +import logging |
14 | import mimetypes | 14 | import mimetypes |
15 | from os import path | 15 | from os import path |
16 | import re | 16 | import re |
17 | import signal | 17 | import signal |
18 | import sys | 18 | import sys |
19 | from timeit import default_timer as timer | 19 | from timeit import default_timer as timer |
20 | +from typing import Dict, Tuple | ||
20 | import uuid | 21 | import uuid |
21 | 22 | ||
22 | # user installed libraries | 23 | # user installed libraries |
23 | import tornado.ioloop | 24 | import tornado.ioloop |
24 | import tornado.web | 25 | import tornado.web |
25 | -# import tornado.websocket | ||
26 | import tornado.httpserver | 26 | import tornado.httpserver |
27 | 27 | ||
28 | # this project | 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,8 +45,6 @@ class WebApplication(tornado.web.Application): | ||
41 | (r'/review', ReviewHandler), | 45 | (r'/review', ReviewHandler), |
42 | (r'/admin', AdminHandler), | 46 | (r'/admin', AdminHandler), |
43 | (r'/file', FileHandler), | 47 | (r'/file', FileHandler), |
44 | - # (r'/root', MainHandler), | ||
45 | - # (r'/ws', AdminSocketHandler), | ||
46 | (r'/adminwebservice', AdminWebservice), | 48 | (r'/adminwebservice', AdminWebservice), |
47 | (r'/studentwebservice', StudentWebservice), | 49 | (r'/studentwebservice', StudentWebservice), |
48 | (r'/', RootHandler), | 50 | (r'/', RootHandler), |
@@ -64,11 +66,10 @@ class WebApplication(tornado.web.Application): | @@ -64,11 +66,10 @@ class WebApplication(tornado.web.Application): | ||
64 | # ---------------------------------------------------------------------------- | 66 | # ---------------------------------------------------------------------------- |
65 | def admin_only(func): | 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 | @functools.wraps(func) | 74 | @functools.wraps(func) |
74 | async def wrapper(self, *args, **kwargs): | 75 | async def wrapper(self, *args, **kwargs): |
@@ -92,6 +93,11 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -92,6 +93,11 @@ class BaseHandler(tornado.web.RequestHandler): | ||
92 | '''simplifies access to the application a little bit''' | 93 | '''simplifies access to the application a little bit''' |
93 | return self.application.testapp | 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 | def get_current_user(self): | 101 | def get_current_user(self): |
96 | ''' | 102 | ''' |
97 | Since HTTP is stateless, a cookie is used to identify the user. | 103 | Since HTTP is stateless, a cookie is used to identify the user. |
@@ -104,72 +110,15 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -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 | # pylint: disable=abstract-method | 113 | # pylint: disable=abstract-method |
165 | class LoginHandler(BaseHandler): | 114 | class LoginHandler(BaseHandler): |
166 | '''Handles /login''' | 115 | '''Handles /login''' |
167 | 116 | ||
168 | _prefix = re.compile(r'[a-z]') | 117 | _prefix = re.compile(r'[a-z]') |
169 | _error_msg = { | 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 | def get(self): | 124 | def get(self): |
@@ -178,7 +127,7 @@ class LoginHandler(BaseHandler): | @@ -178,7 +127,7 @@ class LoginHandler(BaseHandler): | ||
178 | 127 | ||
179 | async def post(self): | 128 | async def post(self): |
180 | '''Authenticates student and login.''' | 129 | '''Authenticates student and login.''' |
181 | - uid = self._prefix.sub('', self.get_body_argument('uid')) | 130 | + uid = self.get_body_argument('uid') |
182 | password = self.get_body_argument('pw') | 131 | password = self.get_body_argument('pw') |
183 | headers = { | 132 | headers = { |
184 | 'remote_ip': self.request.remote_ip, | 133 | 'remote_ip': self.request.remote_ip, |
@@ -187,7 +136,7 @@ class LoginHandler(BaseHandler): | @@ -187,7 +136,7 @@ class LoginHandler(BaseHandler): | ||
187 | 136 | ||
188 | error = await self.testapp.login(uid, password, headers) | 137 | error = await self.testapp.login(uid, password, headers) |
189 | 138 | ||
190 | - if error: | 139 | + if error is not None: |
191 | await asyncio.sleep(3) # delay to avoid spamming the server... | 140 | await asyncio.sleep(3) # delay to avoid spamming the server... |
192 | self.render('login.html', error=self._error_msg[error]) | 141 | self.render('login.html', error=self._error_msg[error]) |
193 | else: | 142 | else: |
@@ -203,8 +152,8 @@ class LogoutHandler(BaseHandler): | @@ -203,8 +152,8 @@ class LogoutHandler(BaseHandler): | ||
203 | @tornado.web.authenticated | 152 | @tornado.web.authenticated |
204 | def get(self): | 153 | def get(self): |
205 | '''Logs out a user.''' | 154 | '''Logs out a user.''' |
206 | - self.clear_cookie('perguntations_user') | ||
207 | self.testapp.logout(self.current_user) | 155 | self.testapp.logout(self.current_user) |
156 | + self.clear_cookie('perguntations_user') | ||
208 | self.render('login.html', error='') | 157 | self.render('login.html', error='') |
209 | 158 | ||
210 | 159 | ||
@@ -214,7 +163,7 @@ class LogoutHandler(BaseHandler): | @@ -214,7 +163,7 @@ class LogoutHandler(BaseHandler): | ||
214 | # pylint: disable=abstract-method | 163 | # pylint: disable=abstract-method |
215 | class RootHandler(BaseHandler): | 164 | class RootHandler(BaseHandler): |
216 | ''' | 165 | ''' |
217 | - Generates test to student. | 166 | + Presents test to student. |
218 | Receives answers, corrects the test and sends back the grade. | 167 | Receives answers, corrects the test and sends back the grade. |
219 | Redirects user 0 to /admin. | 168 | Redirects user 0 to /admin. |
220 | ''' | 169 | ''' |
@@ -227,7 +176,6 @@ class RootHandler(BaseHandler): | @@ -227,7 +176,6 @@ class RootHandler(BaseHandler): | ||
227 | 'text-regex': 'question-text.html', | 176 | 'text-regex': 'question-text.html', |
228 | 'numeric-interval': 'question-text.html', | 177 | 'numeric-interval': 'question-text.html', |
229 | 'textarea': 'question-textarea.html', | 178 | 'textarea': 'question-textarea.html', |
230 | - 'code': 'question-textarea.html', | ||
231 | # -- information panels -- | 179 | # -- information panels -- |
232 | 'information': 'question-information.html', | 180 | 'information': 'question-information.html', |
233 | 'success': 'question-information.html', | 181 | 'success': 'question-information.html', |
@@ -243,77 +191,67 @@ class RootHandler(BaseHandler): | @@ -243,77 +191,67 @@ class RootHandler(BaseHandler): | ||
243 | Sends test to student or redirects 0 to admin page. | 191 | Sends test to student or redirects 0 to admin page. |
244 | Multiple calls to this function will return the same test. | 192 | Multiple calls to this function will return the same test. |
245 | ''' | 193 | ''' |
246 | - | ||
247 | uid = self.current_user | 194 | uid = self.current_user |
248 | - logging.debug('"%s" GET /', uid) | 195 | + logger.debug('"%s" GET /', uid) |
249 | 196 | ||
250 | if uid == '0': | 197 | if uid == '0': |
251 | self.redirect('/admin') | 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 | # --- POST | 205 | # --- POST |
259 | @tornado.web.authenticated | 206 | @tornado.web.authenticated |
260 | async def post(self): | 207 | async def post(self): |
261 | ''' | 208 | ''' |
262 | Receives answers, fixes some html weirdness, corrects test and | 209 | Receives answers, fixes some html weirdness, corrects test and |
263 | - sends back the grade. | 210 | + renders the grade. |
264 | 211 | ||
265 | self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} | 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 | uid = self.current_user | 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 | ans = {} | 226 | ans = {} |
281 | for i, question in enumerate(test['questions']): | 227 | for i, question in enumerate(test['questions']): |
282 | qid = str(i) | 228 | qid = str(i) |
283 | - if 'answered-' + qid in self.request.arguments: | 229 | + if f'answered-{qid}' in self.request.arguments: |
284 | ans[i] = self.get_body_arguments(qid) | 230 | ans[i] = self.get_body_arguments(qid) |
285 | 231 | ||
286 | # remove enclosing list in some question types | 232 | # remove enclosing list in some question types |
287 | if question['type'] == 'radio': | 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 | elif question['type'] in ('text', 'text-regex', 'textarea', | 235 | elif question['type'] in ('text', 'text-regex', 'textarea', |
293 | - 'numeric-interval', 'code'): | 236 | + 'numeric-interval'): |
294 | ans[i] = ans[i][0] | 237 | ans[i] = ans[i][0] |
295 | 238 | ||
296 | # submit answered questions, correct | 239 | # submit answered questions, correct |
297 | await self.testapp.submit_test(uid, ans) | 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 | self.clear_cookie('perguntations_user') | 244 | self.clear_cookie('perguntations_user') |
305 | self.testapp.logout(uid) | 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 | # pylint: disable=abstract-method | 250 | # pylint: disable=abstract-method |
313 | class StudentWebservice(BaseHandler): | 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 | @tornado.web.authenticated | 257 | @tornado.web.authenticated |
@@ -321,8 +259,9 @@ class StudentWebservice(BaseHandler): | @@ -321,8 +259,9 @@ class StudentWebservice(BaseHandler): | ||
321 | '''handle ajax post''' | 259 | '''handle ajax post''' |
322 | uid = self.current_user | 260 | uid = self.current_user |
323 | cmd = self.get_body_argument('cmd', None) | 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,16 +276,17 @@ class AdminWebservice(BaseHandler): | ||
337 | async def get(self): | 276 | async def get(self): |
338 | '''admin webservices that do not change state''' | 277 | '''admin webservices that do not change state''' |
339 | cmd = self.get_query_argument('cmd') | 278 | cmd = self.get_query_argument('cmd') |
279 | + logger.debug('GET /adminwebservice %s', cmd) | ||
280 | + | ||
340 | if cmd == 'testcsv': | 281 | if cmd == 'testcsv': |
341 | - test_ref, data = self.testapp.get_test_csv() | 282 | + test_ref, data = self.testapp.get_grades_csv() |
342 | self.set_header('Content-Type', 'text/csv') | 283 | self.set_header('Content-Type', 'text/csv') |
343 | self.set_header('content-Disposition', | 284 | self.set_header('content-Disposition', |
344 | f'attachment; filename={test_ref}.csv') | 285 | f'attachment; filename={test_ref}.csv') |
345 | self.write(data) | 286 | self.write(data) |
346 | await self.flush() | 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 | self.set_header('Content-Type', 'text/csv') | 290 | self.set_header('Content-Type', 'text/csv') |
351 | self.set_header('content-Disposition', | 291 | self.set_header('content-Disposition', |
352 | f'attachment; filename={test_ref}-detailed.csv') | 292 | f'attachment; filename={test_ref}-detailed.csv') |
@@ -359,6 +299,7 @@ class AdminWebservice(BaseHandler): | @@ -359,6 +299,7 @@ class AdminWebservice(BaseHandler): | ||
359 | class AdminHandler(BaseHandler): | 299 | class AdminHandler(BaseHandler): |
360 | '''Handle /admin''' | 300 | '''Handle /admin''' |
361 | 301 | ||
302 | + # --- GET | ||
362 | @tornado.web.authenticated | 303 | @tornado.web.authenticated |
363 | @admin_only | 304 | @admin_only |
364 | async def get(self): | 305 | async def get(self): |
@@ -366,24 +307,18 @@ class AdminHandler(BaseHandler): | @@ -366,24 +307,18 @@ class AdminHandler(BaseHandler): | ||
366 | Admin page. | 307 | Admin page. |
367 | ''' | 308 | ''' |
368 | cmd = self.get_query_argument('cmd', default=None) | 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 | elif cmd == 'test': | 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 | self.write(json.dumps(data, default=str)) | 319 | self.write(json.dumps(data, default=str)) |
384 | - else: | ||
385 | - self.render('admin.html') | ||
386 | 320 | ||
321 | + # --- POST | ||
387 | @tornado.web.authenticated | 322 | @tornado.web.authenticated |
388 | @admin_only | 323 | @admin_only |
389 | async def post(self): | 324 | async def post(self): |
@@ -392,6 +327,7 @@ class AdminHandler(BaseHandler): | @@ -392,6 +327,7 @@ class AdminHandler(BaseHandler): | ||
392 | ''' | 327 | ''' |
393 | cmd = self.get_body_argument('cmd', None) | 328 | cmd = self.get_body_argument('cmd', None) |
394 | value = self.get_body_argument('value', None) | 329 | value = self.get_body_argument('value', None) |
330 | + logger.debug('POST /admin (cmd=%s, value=%s)') | ||
395 | 331 | ||
396 | if cmd == 'allow': | 332 | if cmd == 'allow': |
397 | self.testapp.allow_student(value) | 333 | self.testapp.allow_student(value) |
@@ -402,15 +338,11 @@ class AdminHandler(BaseHandler): | @@ -402,15 +338,11 @@ class AdminHandler(BaseHandler): | ||
402 | elif cmd == 'deny_all': | 338 | elif cmd == 'deny_all': |
403 | self.testapp.deny_all_students() | 339 | self.testapp.deny_all_students() |
404 | elif cmd == 'reset_password': | 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 | student = json.loads(value) | 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,6 +354,7 @@ class FileHandler(BaseHandler): | ||
422 | Handles static files from questions like images, etc. | 354 | Handles static files from questions like images, etc. |
423 | ''' | 355 | ''' |
424 | 356 | ||
357 | + _filecache: Dict[Tuple[str, str], bytes] = {} | ||
425 | 358 | ||
426 | @tornado.web.authenticated | 359 | @tornado.web.authenticated |
427 | async def get(self): | 360 | async def get(self): |
@@ -429,40 +362,47 @@ class FileHandler(BaseHandler): | @@ -429,40 +362,47 @@ class FileHandler(BaseHandler): | ||
429 | Returns requested file. Files are obtained from the 'public' directory | 362 | Returns requested file. Files are obtained from the 'public' directory |
430 | of each question. | 363 | of each question. |
431 | ''' | 364 | ''' |
432 | - | ||
433 | uid = self.current_user | 365 | uid = self.current_user |
434 | ref = self.get_query_argument('ref', None) | 366 | ref = self.get_query_argument('ref', None) |
435 | image = self.get_query_argument('image', None) | 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 | content_type = mimetypes.guess_type(image)[0] | 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 | for question in test['questions']: | 390 | for question in test['questions']: |
448 | - # search for the question that contains the image | ||
449 | if question['ref'] == ref: | 391 | if question['ref'] == ref: |
450 | filepath = path.join(question['path'], 'public', image) | 392 | filepath = path.join(question['path'], 'public', image) |
393 | + | ||
451 | try: | 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 | except OSError: | 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 | self.set_header("Content-Type", content_type) | 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 | # --- REVIEW ----------------------------------------------------------------- | 408 | # --- REVIEW ----------------------------------------------------------------- |
@@ -479,7 +419,6 @@ class ReviewHandler(BaseHandler): | @@ -479,7 +419,6 @@ class ReviewHandler(BaseHandler): | ||
479 | 'text-regex': 'review-question-text.html', | 419 | 'text-regex': 'review-question-text.html', |
480 | 'numeric-interval': 'review-question-text.html', | 420 | 'numeric-interval': 'review-question-text.html', |
481 | 'textarea': 'review-question-text.html', | 421 | 'textarea': 'review-question-text.html', |
482 | - 'code': 'review-question-text.html', | ||
483 | # -- information panels -- | 422 | # -- information panels -- |
484 | 'information': 'review-question-information.html', | 423 | 'information': 'review-question-information.html', |
485 | 'success': 'review-question-information.html', | 424 | 'success': 'review-question-information.html', |
@@ -494,26 +433,28 @@ class ReviewHandler(BaseHandler): | @@ -494,26 +433,28 @@ class ReviewHandler(BaseHandler): | ||
494 | Opens JSON file with a given corrected test and renders it | 433 | Opens JSON file with a given corrected test and renders it |
495 | ''' | 434 | ''' |
496 | test_id = self.get_query_argument('test_id', None) | 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 | fname = self.testapp.get_json_filename_of_test(test_id) | 437 | fname = self.testapp.get_json_filename_of_test(test_id) |
499 | 438 | ||
500 | if fname is None: | 439 | if fname is None: |
501 | raise tornado.web.HTTPError(404) # Not Found | 440 | raise tornado.web.HTTPError(404) # Not Found |
502 | 441 | ||
503 | try: | 442 | try: |
504 | - with open(path.expanduser(fname)) as jsonfile: | 443 | + with open(path.expanduser(fname), encoding='utf-8') as jsonfile: |
505 | test = json.load(jsonfile) | 444 | test = json.load(jsonfile) |
506 | except OSError: | 445 | except OSError: |
507 | msg = f'Cannot open "{fname}" for review.' | 446 | msg = f'Cannot open "{fname}" for review.' |
508 | - logging.error(msg) | 447 | + logger.error(msg) |
509 | raise tornado.web.HTTPError(status_code=404, reason=msg) from None | 448 | raise tornado.web.HTTPError(status_code=404, reason=msg) from None |
510 | except json.JSONDecodeError as exc: | 449 | except json.JSONDecodeError as exc: |
511 | msg = f'JSON error in "{fname}": {exc}' | 450 | msg = f'JSON error in "{fname}": {exc}' |
512 | - logging.error(msg) | 451 | + logger.error(msg) |
513 | raise tornado.web.HTTPError(status_code=404, reason=msg) | 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,7 +465,7 @@ def signal_handler(*_): | ||
524 | reply = input(' --> Stop webserver? (yes/no) ') | 465 | reply = input(' --> Stop webserver? (yes/no) ') |
525 | if reply.lower() == 'yes': | 466 | if reply.lower() == 'yes': |
526 | tornado.ioloop.IOLoop.current().stop() | 467 | tornado.ioloop.IOLoop.current().stop() |
527 | - logging.critical('Webserver stopped.') | 468 | + logger.critical('Webserver stopped.') |
528 | sys.exit(0) | 469 | sys.exit(0) |
529 | 470 | ||
530 | # ---------------------------------------------------------------------------- | 471 | # ---------------------------------------------------------------------------- |
@@ -534,33 +475,33 @@ def run_webserver(app, ssl_opt, port, debug): | @@ -534,33 +475,33 @@ def run_webserver(app, ssl_opt, port, debug): | ||
534 | ''' | 475 | ''' |
535 | 476 | ||
536 | # --- create web application | 477 | # --- create web application |
537 | - logging.info('-----------------------------------------------------------') | ||
538 | - logging.info('Starting WebApplication (tornado)') | 478 | + logger.info('-------- Starting WebApplication (tornado) --------') |
539 | try: | 479 | try: |
540 | webapp = WebApplication(app, debug=debug) | 480 | webapp = WebApplication(app, debug=debug) |
541 | except Exception: | 481 | except Exception: |
542 | - logging.critical('Failed to start web application.') | 482 | + logger.critical('Failed to start web application.') |
543 | raise | 483 | raise |
544 | 484 | ||
485 | + # --- create httpserver | ||
545 | try: | 486 | try: |
546 | httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt) | 487 | httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt) |
547 | except ValueError: | 488 | except ValueError: |
548 | - logging.critical('Certificates cert.pem, privkey.pem not found') | 489 | + logger.critical('Certificates cert.pem, privkey.pem not found') |
549 | sys.exit(1) | 490 | sys.exit(1) |
550 | 491 | ||
551 | try: | 492 | try: |
552 | httpserver.listen(port) | 493 | httpserver.listen(port) |
553 | except OSError: | 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 | sys.exit(1) | 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 | signal.signal(signal.SIGINT, signal_handler) | 499 | signal.signal(signal.SIGINT, signal_handler) |
559 | 500 | ||
560 | # --- run webserver | 501 | # --- run webserver |
561 | try: | 502 | try: |
562 | tornado.ioloop.IOLoop.current().start() # running... | 503 | tornado.ioloop.IOLoop.current().start() # running... |
563 | except Exception: | 504 | except Exception: |
564 | - logging.critical('Webserver stopped!') | 505 | + logger.critical('Webserver stopped!') |
565 | tornado.ioloop.IOLoop.current().stop() | 506 | tornado.ioloop.IOLoop.current().stop() |
566 | raise | 507 | raise |
perguntations/static/js/admin.js
@@ -117,16 +117,13 @@ $(document).ready(function() { | @@ -117,16 +117,13 @@ $(document).ready(function() { | ||
117 | d = json.data[i]; | 117 | d = json.data[i]; |
118 | var uid = d['uid']; | 118 | var uid = d['uid']; |
119 | var checked = d['allowed'] ? 'checked' : ''; | 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 | 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>': ''; | 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 | var unfocus = d['unfocus'] ? ' <span class="badge badge-danger">unfocus</span>' : ''; | 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 | var g = d['grades']; | 127 | var g = d['grades']; |
131 | 128 | ||
132 | t[i] = []; | 129 | t[i] = []; |
@@ -134,7 +131,7 @@ $(document).ready(function() { | @@ -134,7 +131,7 @@ $(document).ready(function() { | ||
134 | t[i][1] = '<input type="checkbox" name="' + uid + '" value="true"' + checked + '> '; | 131 | t[i][1] = '<input type="checkbox" name="' + uid + '" value="true"' + checked + '> '; |
135 | t[i][2] = uid; | 132 | t[i][2] = uid; |
136 | t[i][3] = d['name']; | 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 | var gbar = ''; | 136 | var gbar = ''; |
140 | for (var j=0; j < g.length; j++) | 137 | for (var j=0; j < g.length; j++) |
perguntations/templates/admin.html
@@ -53,8 +53,8 @@ | @@ -53,8 +53,8 @@ | ||
53 | Acções | 53 | Acções |
54 | </a> | 54 | </a> |
55 | <div class="dropdown-menu dropdown-menu-right" aria-labelledby="navbarDropdownAluno"> | 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 | <a class="dropdown-item" href="#" id="allow_all">Autorizar todos</a> | 58 | <a class="dropdown-item" href="#" id="allow_all">Autorizar todos</a> |
59 | <a class="dropdown-item" href="#" id="deny_all">Desautorizar todos</a> | 59 | <a class="dropdown-item" href="#" id="deny_all">Desautorizar todos</a> |
60 | <div class="dropdown-divider"></div> | 60 | <div class="dropdown-divider"></div> |
@@ -72,7 +72,7 @@ | @@ -72,7 +72,7 @@ | ||
72 | <p> | 72 | <p> |
73 | Referência: <code id="ref">--</code><br> | 73 | Referência: <code id="ref">--</code><br> |
74 | Ficheiro de configuração do teste: <code id="filename">--</code><br> | 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 | Base de dados: <code id="database">--</code><br> | 76 | Base de dados: <code id="database">--</code><br> |
77 | </p> | 77 | </p> |
78 | <p> | 78 | <p> |
perguntations/templates/grade.html
@@ -31,8 +31,8 @@ | @@ -31,8 +31,8 @@ | ||
31 | </ul> | 31 | </ul> |
32 | <span class="navbar-text"> | 32 | <span class="navbar-text"> |
33 | <i class="fas fa-user" aria-hidden="true"></i> | 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 | <span class="caret"></span> | 36 | <span class="caret"></span> |
37 | </span> | 37 | </span> |
38 | </div> | 38 | </div> |
perguntations/templates/question-information.html
@@ -17,9 +17,9 @@ | @@ -17,9 +17,9 @@ | ||
17 | {{ md(q['text']) }} | 17 | {{ md(q['text']) }} |
18 | </div> | 18 | </div> |
19 | 19 | ||
20 | - {% if show_ref %} | 20 | + {% if debug %} |
21 | <hr> | 21 | <hr> |
22 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 22 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
23 | ref: <code>{{ q['ref'] }}</code> | 23 | ref: <code>{{ q['ref'] }}</code> |
24 | {% end %} | 24 | {% end %} |
25 | -</div> | ||
26 | \ No newline at end of file | 25 | \ No newline at end of file |
26 | +</div> |
perguntations/templates/question.html
@@ -29,11 +29,11 @@ | @@ -29,11 +29,11 @@ | ||
29 | </p> | 29 | </p> |
30 | </div> | 30 | </div> |
31 | 31 | ||
32 | - {% if show_ref %} | 32 | + {% if debug %} |
33 | <div class="card-footer"> | 33 | <div class="card-footer"> |
34 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 34 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
35 | ref: <code>{{ q['ref'] }}</code> | 35 | ref: <code>{{ q['ref'] }}</code> |
36 | </div> | 36 | </div> |
37 | {% end %} | 37 | {% end %} |
38 | </div> | 38 | </div> |
39 | -{% end %} | ||
40 | \ No newline at end of file | 39 | \ No newline at end of file |
40 | +{% end %} |
perguntations/templates/review-question-information.html
@@ -16,9 +16,9 @@ | @@ -16,9 +16,9 @@ | ||
16 | <div id="text"> | 16 | <div id="text"> |
17 | {{ md(q['text']) }} | 17 | {{ md(q['text']) }} |
18 | </div> | 18 | </div> |
19 | - {% if t['show_ref'] %} | 19 | + {% if debug %} |
20 | <hr> | 20 | <hr> |
21 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 21 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
22 | ref: <code>{{ q['ref'] }}</code> | 22 | ref: <code>{{ q['ref'] }}</code> |
23 | {% end %} | 23 | {% end %} |
24 | -</div> | ||
25 | \ No newline at end of file | 24 | \ No newline at end of file |
25 | +</div> |
perguntations/templates/review-question.html
@@ -65,7 +65,7 @@ | @@ -65,7 +65,7 @@ | ||
65 | {% end %} | 65 | {% end %} |
66 | {% end %} | 66 | {% end %} |
67 | 67 | ||
68 | - {% if t['show_ref'] %} | 68 | + {% if debug %} |
69 | <hr> | 69 | <hr> |
70 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 70 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
71 | ref: <code>{{ q['ref'] }}</code> | 71 | ref: <code>{{ q['ref'] }}</code> |
@@ -109,7 +109,7 @@ | @@ -109,7 +109,7 @@ | ||
109 | {% end %} | 109 | {% end %} |
110 | </p> | 110 | </p> |
111 | 111 | ||
112 | - {% if t['show_ref'] %} | 112 | + {% if debug %} |
113 | <hr> | 113 | <hr> |
114 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | 114 | file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
115 | ref: <code>{{ q['ref'] }}</code> | 115 | ref: <code>{{ q['ref'] }}</code> |
@@ -118,4 +118,4 @@ | @@ -118,4 +118,4 @@ | ||
118 | </div> <!-- card-footer --> | 118 | </div> <!-- card-footer --> |
119 | </div> <!-- card --> | 119 | </div> <!-- card --> |
120 | {% end %} <!-- if answer not None --> | 120 | {% end %} <!-- if answer not None --> |
121 | -{% end %} <!-- block --> | ||
122 | \ No newline at end of file | 121 | \ No newline at end of file |
122 | +{% end %} <!-- block --> |
perguntations/templates/review.html
@@ -59,8 +59,8 @@ | @@ -59,8 +59,8 @@ | ||
59 | <li class="nav-item"> | 59 | <li class="nav-item"> |
60 | <span class="navbar-text"> | 60 | <span class="navbar-text"> |
61 | <i class="fas fa-user" aria-hidden="true"></i> | 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 | <span class="caret"></span> | 64 | <span class="caret"></span> |
65 | </span> | 65 | </span> |
66 | </li> | 66 | </li> |
@@ -97,9 +97,12 @@ | @@ -97,9 +97,12 @@ | ||
97 | <div class="row"> | 97 | <div class="row"> |
98 | <label for="nota" class="col-sm-2">Nota:</label> | 98 | <label for="nota" class="col-sm-2">Nota:</label> |
99 | <div class="col-sm-10" id="nota"> | 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 | {% end %} | 106 | {% end %} |
104 | </div> | 107 | </div> |
105 | </div> | 108 | </div> |
@@ -113,7 +116,7 @@ | @@ -113,7 +116,7 @@ | ||
113 | </div> <!-- jumbotron --> | 116 | </div> <!-- jumbotron --> |
114 | 117 | ||
115 | {% for i, q in enumerate(t['questions']) %} | 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 | {% end %} | 120 | {% end %} |
118 | 121 | ||
119 | </div> <!-- container --> | 122 | </div> <!-- container --> |
perguntations/templates/test.html
@@ -74,8 +74,8 @@ | @@ -74,8 +74,8 @@ | ||
74 | <li class="nav-item"> | 74 | <li class="nav-item"> |
75 | <span class="navbar-text"> | 75 | <span class="navbar-text"> |
76 | <i class="fas fa-user" aria-hidden="true"></i> | 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 | <span class="caret"></span> | 79 | <span class="caret"></span> |
80 | </span> | 80 | </span> |
81 | </li> | 81 | </li> |
@@ -93,11 +93,11 @@ | @@ -93,11 +93,11 @@ | ||
93 | 93 | ||
94 | <div class="row"> | 94 | <div class="row"> |
95 | <label for="nome" class="col-sm-3">Nome:</label> | 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 | </div> | 97 | </div> |
98 | <div class="row"> | 98 | <div class="row"> |
99 | <label for="numero" class="col-sm-3">Número:</label> | 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 | </div> | 101 | </div> |
102 | 102 | ||
103 | <div class="row"> | 103 | <div class="row"> |
@@ -114,12 +114,14 @@ | @@ -114,12 +114,14 @@ | ||
114 | {% module xsrf_form_html() %} | 114 | {% module xsrf_form_html() %} |
115 | 115 | ||
116 | {% for i, q in enumerate(t['questions']) %} | 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 | {% end %} | 118 | {% end %} |
119 | 119 | ||
120 | <div class="form-row"> | 120 | <div class="form-row"> |
121 | <div class="col-12"> | 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 | </div> | 125 | </div> |
124 | </div> | 126 | </div> |
125 | </form> | 127 | </form> |
@@ -138,11 +140,16 @@ | @@ -138,11 +140,16 @@ | ||
138 | <div class="modal-body"> | 140 | <div class="modal-body"> |
139 | O teste será enviado para classificação e já não poderá voltar atrás. | 141 | O teste será enviado para classificação e já não poderá voltar atrás. |
140 | Antes de submeter, verifique se respondeu a todas as questões. | 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 | </div> | 145 | </div> |
143 | <div class="modal-footer"> | 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 | </div> | 153 | </div> |
147 | </div> | 154 | </div> |
148 | </div> | 155 | </div> |
perguntations/test.py
@@ -7,9 +7,8 @@ from datetime import datetime | @@ -7,9 +7,8 @@ from datetime import datetime | ||
7 | import json | 7 | import json |
8 | import logging | 8 | import logging |
9 | from math import nan | 9 | from math import nan |
10 | -from os import path | ||
11 | 10 | ||
12 | -# Logger configuration | 11 | + |
13 | logger = logging.getLogger(__name__) | 12 | logger = logging.getLogger(__name__) |
14 | 13 | ||
15 | 14 | ||
@@ -30,17 +29,17 @@ class Test(dict): | @@ -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 | super().__init__(d) | 33 | super().__init__(d) |
35 | self['grade'] = nan | 34 | self['grade'] = nan |
36 | self['comment'] = '' | 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 | self['start_time'] = datetime.now() | 43 | self['start_time'] = datetime.now() |
45 | self['finish_time'] = None | 44 | self['finish_time'] = None |
46 | self['state'] = 'ACTIVE' | 45 | self['state'] = 'ACTIVE' |
@@ -57,14 +56,14 @@ class Test(dict): | @@ -57,14 +56,14 @@ class Test(dict): | ||
57 | self['questions'][ref].set_answer(ans) | 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 | Given a dictionary ans={'ref': 'some answer'} updates the answers of | 61 | Given a dictionary ans={'ref': 'some answer'} updates the answers of |
63 | multiple questions in the test. | 62 | multiple questions in the test. |
64 | Only affects the questions referred in the dictionary. | 63 | Only affects the questions referred in the dictionary. |
65 | ''' | 64 | ''' |
66 | self['finish_time'] = datetime.now() | 65 | self['finish_time'] = datetime.now() |
67 | - for ref, ans in answers_dict.items(): | 66 | + for ref, ans in answers.items(): |
68 | self['questions'][ref].set_answer(ans) | 67 | self['questions'][ref].set_answer(ans) |
69 | self['state'] = 'SUBMITTED' | 68 | self['state'] = 'SUBMITTED' |
70 | 69 | ||
@@ -104,15 +103,11 @@ class Test(dict): | @@ -104,15 +103,11 @@ class Test(dict): | ||
104 | self['grade'] = 0.0 | 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 | '''save test in JSON format''' | 107 | '''save test in JSON format''' |
109 | - with open(pathfile, 'w') as file: | 108 | + with open(filename, 'w', encoding='utf-8') as file: |
110 | json.dump(self, file, indent=2, default=str) # str for datetime | 109 | json.dump(self, file, indent=2, default=str) # str for datetime |
111 | 110 | ||
112 | # ------------------------------------------------------------------------ | 111 | # ------------------------------------------------------------------------ |
113 | def __str__(self) -> str: | 112 | def __str__(self) -> str: |
114 | return '\n'.join([f'{k}: {v}' for k,v in self.items()]) | 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,20 +3,65 @@ TestFactory - generates tests for students | ||
3 | ''' | 3 | ''' |
4 | 4 | ||
5 | # python standard library | 5 | # python standard library |
6 | +import asyncio | ||
6 | from os import path | 7 | from os import path |
7 | import random | 8 | import random |
8 | import logging | 9 | import logging |
9 | -import re | ||
10 | -from typing import Any, Dict | 10 | + |
11 | +# other libraries | ||
12 | +import schema | ||
11 | 13 | ||
12 | # this project | 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 | # Logger configuration | 19 | # Logger configuration |
18 | logger = logging.getLogger(__name__) | 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 | class TestFactoryException(Exception): | 67 | class TestFactoryException(Exception): |
@@ -32,34 +77,30 @@ class TestFactory(dict): | @@ -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 | Loads configuration from yaml file, then overrides some configurations | 82 | Loads configuration from yaml file, then overrides some configurations |
38 | using the conf argument. | 83 | using the conf argument. |
39 | Base questions are added to a pool of questions factories. | 84 | Base questions are added to a pool of questions factories. |
40 | ''' | 85 | ''' |
41 | 86 | ||
87 | + test_schema.validate(conf) | ||
88 | + | ||
42 | # --- set test defaults and then use given configuration | 89 | # --- set test defaults and then use given configuration |
43 | super().__init__({ # defaults | 90 | super().__init__({ # defaults |
44 | - 'title': '', | ||
45 | 'show_points': True, | 91 | 'show_points': True, |
46 | 'scale': None, | 92 | 'scale': None, |
47 | 'duration': 0, # 0=infinite | 93 | 'duration': 0, # 0=infinite |
48 | 'autosubmit': False, | 94 | 'autosubmit': False, |
49 | 'autocorrect': True, | 95 | 'autocorrect': True, |
50 | - 'debug': False, | ||
51 | - 'show_ref': False, | ||
52 | }) | 96 | }) |
53 | self.update(conf) | 97 | self.update(conf) |
98 | + normalize_question_list(self['questions']) | ||
54 | 99 | ||
55 | # --- for review, we are done. no factories needed | 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 | # --- find refs of all questions used in the test | 105 | # --- find refs of all questions used in the test |
65 | qrefs = {r for qq in self['questions'] for r in qq['ref']} | 106 | qrefs = {r for qq in self['questions'] for r in qq['ref']} |
@@ -70,7 +111,7 @@ class TestFactory(dict): | @@ -70,7 +111,7 @@ class TestFactory(dict): | ||
70 | self['question_factory'] = {} | 111 | self['question_factory'] = {} |
71 | 112 | ||
72 | for file in self["files"]: | 113 | for file in self["files"]: |
73 | - fullpath = path.normpath(path.join(self["questions_dir"], file)) | 114 | + fullpath = path.normpath(file) |
74 | 115 | ||
75 | logger.info('Loading "%s"...', fullpath) | 116 | logger.info('Loading "%s"...', fullpath) |
76 | questions = load_yaml(fullpath) # , default=[]) | 117 | questions = load_yaml(fullpath) # , default=[]) |
@@ -81,32 +122,25 @@ class TestFactory(dict): | @@ -81,32 +122,25 @@ class TestFactory(dict): | ||
81 | msg = f'Question {i} in {file} is not a dictionary' | 122 | msg = f'Question {i} in {file} is not a dictionary' |
82 | raise TestFactoryException(msg) | 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 | if 'ref' not in question: | 126 | if 'ref' not in question: |
86 | question['ref'] = f'{file}:{i:04}' | 127 | question['ref'] = f'{file}:{i:04}' |
87 | logger.warning('Missing ref set to "%s"', question["ref"]) | 128 | logger.warning('Missing ref set to "%s"', question["ref"]) |
88 | 129 | ||
89 | # check for duplicate refs | 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 | otherfile = path.join(other.question['path'], | 134 | otherfile = path.join(other.question['path'], |
93 | other.question['filename']) | 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 | raise TestFactoryException(msg) | 137 | raise TestFactoryException(msg) |
97 | 138 | ||
98 | # make factory only for the questions used in the test | 139 | # make factory only for the questions used in the test |
99 | - if question['ref'] in qrefs: | 140 | + if qref in qrefs: |
100 | question.update(zip(('path', 'filename', 'index'), | 141 | question.update(zip(('path', 'filename', 'index'), |
101 | path.split(fullpath) + (i,))) | 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 | qmissing = qrefs.difference(set(self['question_factory'].keys())) | 145 | qmissing = qrefs.difference(set(self['question_factory'].keys())) |
112 | if qmissing: | 146 | if qmissing: |
@@ -118,157 +152,124 @@ class TestFactory(dict): | @@ -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 | def check_questions(self) -> None: | 255 | def check_questions(self) -> None: |
221 | ''' | 256 | ''' |
222 | checks if questions can be correctly generated and corrected | 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 | for i, (qref, qfact) in enumerate(self['question_factory'].items()): | 262 | for i, (qref, qfact) in enumerate(self['question_factory'].items()): |
226 | try: | 263 | try: |
227 | - question = qfact.generate() | 264 | + question = loop.run_until_complete(qfact.gen_async()) |
228 | except Exception as exc: | 265 | except Exception as exc: |
229 | msg = f'Failed to generate "{qref}"' | 266 | msg = f'Failed to generate "{qref}"' |
230 | raise TestFactoryException(msg) from exc | 267 | raise TestFactoryException(msg) from exc |
231 | else: | 268 | else: |
232 | logger.info('%4d. %s: Ok', i, qref) | 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 | async def generate(self): | 274 | async def generate(self): |
274 | ''' | 275 | ''' |
@@ -326,16 +327,51 @@ class TestFactory(dict): | @@ -326,16 +327,51 @@ class TestFactory(dict): | ||
326 | logger.error('%s errors found!', nerr) | 327 | logger.error('%s errors found!', nerr) |
327 | 328 | ||
328 | # copy these from the test configuratoin to each test instance | 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 | return Test({'questions': questions, **{k:self[k] for k in inherit}}) | 333 | return Test({'questions': questions, **{k:self[k] for k in inherit}}) |
337 | 334 | ||
338 | # ------------------------------------------------------------------------ | 335 | # ------------------------------------------------------------------------ |
339 | def __repr__(self): | 336 | def __repr__(self): |
340 | testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) | 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,28 +19,11 @@ import yaml | ||
20 | logger = logging.getLogger(__name__) | 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 | def run_script(script: str, | 29 | def run_script(script: str, |
@@ -53,7 +35,7 @@ def run_script(script: str, | @@ -53,7 +35,7 @@ def run_script(script: str, | ||
53 | The script is run in another process but this function blocks waiting | 35 | The script is run in another process but this function blocks waiting |
54 | for its termination. | 36 | for its termination. |
55 | ''' | 37 | ''' |
56 | - logger.info('run_script "%s"', script) | 38 | + logger.debug('run_script "%s"', script) |
57 | 39 | ||
58 | output = None | 40 | output = None |
59 | script = path.expanduser(script) | 41 | script = path.expanduser(script) |
setup.py
@@ -22,14 +22,15 @@ setup( | @@ -22,14 +22,15 @@ setup( | ||
22 | url="https://git.xdi.uevora.pt/mjsb/perguntations.git", | 22 | url="https://git.xdi.uevora.pt/mjsb/perguntations.git", |
23 | packages=find_packages(), | 23 | packages=find_packages(), |
24 | include_package_data=True, # install files from MANIFEST.in | 24 | include_package_data=True, # install files from MANIFEST.in |
25 | - python_requires='>=3.7.*', | 25 | + python_requires='>=3.8.*', |
26 | install_requires=[ | 26 | install_requires=[ |
27 | - 'tornado>=6.0', | ||
28 | - 'mistune', | 27 | + 'bcrypt>=3.1', |
28 | + 'mistune<2.0', | ||
29 | 'pyyaml>=5.1', | 29 | 'pyyaml>=5.1', |
30 | 'pygments', | 30 | 'pygments', |
31 | - 'sqlalchemy', | ||
32 | - 'bcrypt>=3.1' | 31 | + 'schema>=0.7.5', |
32 | + 'sqlalchemy>=1.4', | ||
33 | + 'tornado>=6.1', | ||
33 | ], | 34 | ], |
34 | entry_points={ | 35 | entry_points={ |
35 | 'console_scripts': [ | 36 | 'console_scripts': [ |
update.sh