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