Commit 49f4c7a84e5c4d35ccc67e982a568598a80c044c

Authored by Miguel Barão
2 parents f6fdbdbd a0ab1672
Exists in master and in 1 other branch dev

Merge branch 'dev'

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)'
@@ -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
demo/questions/generate-question.py 0 → 100755
@@ -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 # ----------------------------------------------------------------------------
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
@@ -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() -&gt; None: @@ -122,15 +109,16 @@ def main() -&gt; 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() -&gt; None: @@ -138,24 +126,24 @@ def main() -&gt; 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) -&gt; Question: @@ -783,7 +593,6 @@ def question_from(qdict: QDict) -&gt; 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)
@@ -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': [
@@ -3,4 +3,3 @@ @@ -3,4 +3,3 @@
3 git pull 3 git pull
4 npm update 4 npm update
5 pip install -U . 5 pip install -U .
6 -