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