Commit 49f4c7a84e5c4d35ccc67e982a568598a80c044c

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

Merge branch 'dev'

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