Commit 26d268e1b218f655121ecff94fd2f1e883440245
1 parent
053cf0e0
Exists in
master
and in
1 other branch
large refactoring
allow offline correction of tests BREAKING CHANGES: Incompatible database tables from previous versions! Must start with empty database.
Showing
18 changed files
with
1080 additions
and
713 deletions
Show diff stats
BUGS.md
| 1 | 1 | ||
| 2 | # BUGS | 2 | # BUGS |
| 3 | 3 | ||
| 4 | -- grade gives internal server error | 4 | +- cookies existe um perguntations_user e um user. De onde vem o user? |
| 5 | +- nao esta a mostrar imagens?? internal server error? | ||
| 6 | +- JOBE correct async | ||
| 7 | +- esta a corrigir código JOBE mesmo que nao tenha respondido??? | ||
| 8 | +- QuestionCode falta reportar nos comments os vários erros que podem ocorrer (timeout, etc) | ||
| 9 | + | ||
| 10 | +- algumas vezes a base de dados guarda o mesmo teste em duplicado. ver se dois submits dao origem a duas correcções. | ||
| 11 | +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) | ||
| 12 | +- 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. | ||
| 13 | +- grade gives internal server error?? | ||
| 5 | - reload do teste recomeça a contagem no inicio do tempo. | 14 | - reload do teste recomeça a contagem no inicio do tempo. |
| 6 | - 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. | 15 | - 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. |
| 7 | - 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). | 16 | - 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). |
| @@ -11,9 +20,12 @@ | @@ -11,9 +20,12 @@ | ||
| 11 | - Test.reset_answers() unused. | 20 | - Test.reset_answers() unused. |
| 12 | - teste nao esta a mostrar imagens de vez em quando.??? | 21 | - teste nao esta a mostrar imagens de vez em quando.??? |
| 13 | - testar as perguntas todas no início do teste como o aprendizations. | 22 | - testar as perguntas todas no início do teste como o aprendizations. |
| 23 | +- show-ref nao esta a funcionar na correccao (pelo menos) | ||
| 14 | 24 | ||
| 15 | # TODO | 25 | # TODO |
| 16 | 26 | ||
| 27 | +- permitir remover alunos que estão online para poderem comecar de novo. | ||
| 28 | +- 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) | ||
| 17 | - stress tests. use https://locust.io | 29 | - stress tests. use https://locust.io |
| 18 | - wait for admin to start test. (students can be allowed earlier) | 30 | - wait for admin to start test. (students can be allowed earlier) |
| 19 | - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente? | 31 | - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente? |
| @@ -62,6 +74,9 @@ ou usar push (websockets?) | @@ -62,6 +74,9 @@ ou usar push (websockets?) | ||
| 62 | 74 | ||
| 63 | # FIXED | 75 | # FIXED |
| 64 | 76 | ||
| 77 | +- internal server error quando em --review, download csv detalhado. | ||
| 78 | +- perguntas repetidas (mesma ref) dao asneira, porque a referencia é usada como chave em varios sitios e as chaves nao podem ser dupplicadas. | ||
| 79 | + 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. | ||
| 65 | - mostrar unfocus e window area em /admin | 80 | - mostrar unfocus e window area em /admin |
| 66 | - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) | 81 | - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) |
| 67 | - botao de autorizar desliga-se, fazer debounce. | 82 | - botao de autorizar desliga-se, fazer debounce. |
demo/demo.yaml
| @@ -14,6 +14,9 @@ database: students.db | @@ -14,6 +14,9 @@ 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 | + | ||
| 17 | # --- optional settings: ----------------------------------------------------- | 20 | # --- optional settings: ----------------------------------------------------- |
| 18 | 21 | ||
| 19 | # Title of this test, e.g. course name, year or test number | 22 | # Title of this test, e.g. course name, year or test number |
| @@ -26,7 +29,13 @@ duration: 20 | @@ -26,7 +29,13 @@ duration: 20 | ||
| 26 | 29 | ||
| 27 | # Automatic test submission after the given 'duration' timeout | 30 | # Automatic test submission after the given 'duration' timeout |
| 28 | # (default: false) | 31 | # (default: false) |
| 29 | -autosubmit: true | 32 | +autosubmit: false |
| 33 | + | ||
| 34 | +# If true, the test will be corrected on submission, the grade calculated and | ||
| 35 | +# shown to the student. If false, the test is saved but not corrected. | ||
| 36 | +# No grade is shown to the student. | ||
| 37 | +# (default: true) | ||
| 38 | +autocorrect: true | ||
| 30 | 39 | ||
| 31 | # Show points for each question (min and max). | 40 | # Show points for each question (min and max). |
| 32 | # (default: true) | 41 | # (default: true) |
| @@ -37,6 +46,7 @@ show_points: true | @@ -37,6 +46,7 @@ show_points: true | ||
| 37 | # (default: no scaling, just use question points) | 46 | # (default: no scaling, just use question points) |
| 38 | scale: [0, 5] | 47 | scale: [0, 5] |
| 39 | 48 | ||
| 49 | + | ||
| 40 | # ---------------------------------------------------------------------------- | 50 | # ---------------------------------------------------------------------------- |
| 41 | # Base path applied to the questions files and all the scripts | 51 | # Base path applied to the questions files and all the scripts |
| 42 | # including question generators and correctors. | 52 | # including question generators and correctors. |
| @@ -70,19 +80,4 @@ questions: | @@ -70,19 +80,4 @@ questions: | ||
| 70 | - [tut-alert1, tut-alert2] | 80 | - [tut-alert1, tut-alert2] |
| 71 | - tut-generator | 81 | - tut-generator |
| 72 | - tut-yamllint | 82 | - tut-yamllint |
| 73 | - | ||
| 74 | -# test: | ||
| 75 | -# - ref1 | ||
| 76 | -# - block: a | ||
| 77 | -# - block: [b, c] | ||
| 78 | -# - ref2 | ||
| 79 | - | ||
| 80 | -# blocks: | ||
| 81 | -# a: | ||
| 82 | -# - ref1 | ||
| 83 | -# - ref2 | ||
| 84 | -# - ref3 | ||
| 85 | -# b: | ||
| 86 | -# - rr4 | ||
| 87 | -# - rr5 | ||
| 88 | -# - rr6 | 83 | + # - tut-code |
demo/questions/questions-tutorial.yaml
| @@ -26,6 +26,7 @@ | @@ -26,6 +26,7 @@ | ||
| 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]) | 27 | scale: [0, 20] # limites inferior e superior da escala (default: [0,20]) |
| 28 | scale_points: true # normaliza cotações para a escala definida | 28 | scale_points: true # normaliza cotações para a escala definida |
| 29 | + jobe_server: moodle-jobe.uevora.pt # server used to compile & execute code | ||
| 29 | debug: false # mostra informação de debug no browser | 30 | debug: false # mostra informação de debug no browser |
| 30 | 31 | ||
| 31 | # -------------------------------------------------------------------------- | 32 | # -------------------------------------------------------------------------- |
| @@ -575,7 +576,7 @@ | @@ -575,7 +576,7 @@ | ||
| 575 | # ---------------------------------------------------------------------------- | 576 | # ---------------------------------------------------------------------------- |
| 576 | - type: information | 577 | - type: information |
| 577 | text: | | 578 | text: | |
| 578 | - This question is not included in the test and will not shown up. | 579 | + This question is not included in the test and will not show up. |
| 579 | It also lacks a "ref" and is automatically named | 580 | It also lacks a "ref" and is automatically named |
| 580 | `questions/questions-tutorial.yaml:0013`. | 581 | `questions/questions-tutorial.yaml:0013`. |
| 581 | A warning is shown on the console about this. | 582 | A warning is shown on the console about this. |
| @@ -609,3 +610,52 @@ | @@ -609,3 +610,52 @@ | ||
| 609 | generate-question | yamllint - | 610 | generate-question | yamllint - |
| 610 | correct-answer | yamllint - | 611 | correct-answer | yamllint - |
| 611 | ``` | 612 | ``` |
| 613 | + | ||
| 614 | +# ---------------------------------------------------------------------------- | ||
| 615 | +# - type: code | ||
| 616 | +# ref: tut-code | ||
| 617 | +# title: Submissão de código (JOBE) | ||
| 618 | +# text: | | ||
| 619 | +# É possível enviar código para ser compilado e executado por um servidor | ||
| 620 | +# JOBE instalado separadamente, ver [JOBE](https://github.com/trampgeek/jobe). | ||
| 621 | + | ||
| 622 | +# ```yaml | ||
| 623 | +# - type: code | ||
| 624 | +# ref: tut-code | ||
| 625 | +# title: Submissão de código (JOBE) | ||
| 626 | +# text: | | ||
| 627 | +# Escreva um programa em C que recebe uma string no standard input e | ||
| 628 | +# mostra a mensagem `hello ` seguida da string. | ||
| 629 | +# Por exemplo, se o input for `Maria`, o output deverá ser `hello Maria`. | ||
| 630 | +# language: c | ||
| 631 | +# correct: | ||
| 632 | +# - stdin: 'Maria' | ||
| 633 | +# stdout: 'hello Maria' | ||
| 634 | +# - stdin: 'xyz' | ||
| 635 | +# stdout: 'hello xyz' | ||
| 636 | +# ``` | ||
| 637 | + | ||
| 638 | +# Existem várias linguagens suportadas pelo servidor JOBE (C, C++, Java, | ||
| 639 | +# Python2, Python3, Octave, Pascal, PHP). | ||
| 640 | +# O campo `correct` deverá ser uma lista de casos a testar. | ||
| 641 | +# Se um caso incluir `stdin`, este será enviado para o programa e o `stdout` | ||
| 642 | +# obtido será comparado com o declarado. A pergunta é considerada correcta se | ||
| 643 | +# todos os outputs coincidirem. | ||
| 644 | + | ||
| 645 | +# Por defeito é o usado o servidor JOBE declarado no teste. Para usar outro | ||
| 646 | +# diferente nesta pergunta usa-se a opção `server: 127.0.0.1` com o endereço | ||
| 647 | +# apropriado. | ||
| 648 | +# answer: | | ||
| 649 | +# #include <stdio.h> | ||
| 650 | +# int main() { | ||
| 651 | +# char name[20]; | ||
| 652 | +# scanf("%s", name); | ||
| 653 | +# printf("hello %s", name); | ||
| 654 | +# } | ||
| 655 | +# # server: 192.168.1.85 | ||
| 656 | +# language: c | ||
| 657 | +# correct: | ||
| 658 | +# - stdin: 'Maria' | ||
| 659 | +# stdout: 'hello Maria' | ||
| 660 | +# - stdin: 'xyz' | ||
| 661 | +# stdout: 'hello xyz' |
perguntations/__init__.py
| @@ -32,7 +32,7 @@ proof of submission and for review. | @@ -32,7 +32,7 @@ proof of submission and for review. | ||
| 32 | ''' | 32 | ''' |
| 33 | 33 | ||
| 34 | APP_NAME = 'perguntations' | 34 | APP_NAME = 'perguntations' |
| 35 | -APP_VERSION = '2020.11.dev2' | 35 | +APP_VERSION = '2020.12.dev1' |
| 36 | APP_DESCRIPTION = __doc__ | 36 | APP_DESCRIPTION = __doc__ |
| 37 | 37 | ||
| 38 | __author__ = 'Miguel Barão' | 38 | __author__ = 'Miguel Barão' |
perguntations/app.py
| @@ -20,7 +20,9 @@ from sqlalchemy.orm import sessionmaker | @@ -20,7 +20,9 @@ from sqlalchemy.orm import sessionmaker | ||
| 20 | # this project | 20 | # this project |
| 21 | from perguntations.models import Student, Test, Question | 21 | from perguntations.models import Student, Test, Question |
| 22 | from perguntations.tools import load_yaml | 22 | from perguntations.tools import load_yaml |
| 23 | -from perguntations.test import TestFactory, TestFactoryException | 23 | +from perguntations.testfactory import TestFactory, TestFactoryException |
| 24 | +import perguntations.test | ||
| 25 | +from perguntations.questions import question_from | ||
| 24 | 26 | ||
| 25 | logger = logging.getLogger(__name__) | 27 | logger = logging.getLogger(__name__) |
| 26 | 28 | ||
| @@ -33,12 +35,12 @@ class AppException(Exception): | @@ -33,12 +35,12 @@ class AppException(Exception): | ||
| 33 | # ============================================================================ | 35 | # ============================================================================ |
| 34 | # helper functions | 36 | # helper functions |
| 35 | # ============================================================================ | 37 | # ============================================================================ |
| 36 | -async def check_password(try_pw, password): | 38 | +async def check_password(try_pw, hashed_pw): |
| 37 | '''check password in executor''' | 39 | '''check password in executor''' |
| 38 | try_pw = try_pw.encode('utf-8') | 40 | try_pw = try_pw.encode('utf-8') |
| 39 | loop = asyncio.get_running_loop() | 41 | loop = asyncio.get_running_loop() |
| 40 | - hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, password) | ||
| 41 | - return password == hashed | 42 | + hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, hashed_pw) |
| 43 | + return hashed_pw == hashed | ||
| 42 | 44 | ||
| 43 | 45 | ||
| 44 | async def hash_password(password): | 46 | async def hash_password(password): |
| @@ -113,8 +115,7 @@ class App(): | @@ -113,8 +115,7 @@ class App(): | ||
| 113 | else: | 115 | else: |
| 114 | logger.info('Students not yet allowed to login.') | 116 | logger.info('Students not yet allowed to login.') |
| 115 | 117 | ||
| 116 | - # pre-generate tests | ||
| 117 | - | 118 | + # pre-generate tests for allowed students |
| 118 | if self.allowed: | 119 | if self.allowed: |
| 119 | logger.info('Generating %d tests. May take awhile...', | 120 | logger.info('Generating %d tests. May take awhile...', |
| 120 | len(self.allowed)) | 121 | len(self.allowed)) |
| @@ -122,37 +123,98 @@ class App(): | @@ -122,37 +123,98 @@ class App(): | ||
| 122 | else: | 123 | else: |
| 123 | logger.info('No tests were generated.') | 124 | logger.info('No tests were generated.') |
| 124 | 125 | ||
| 126 | + if conf['correct']: | ||
| 127 | + self._correct_tests() | ||
| 128 | + | ||
| 129 | + # ------------------------------------------------------------------------ | ||
| 130 | + def _correct_tests(self): | ||
| 131 | + with self._db_session() as sess: | ||
| 132 | + # Find which tests have to be corrected | ||
| 133 | + dbtests = sess.query(Test)\ | ||
| 134 | + .filter(Test.ref == self.testfactory['ref'])\ | ||
| 135 | + .filter(Test.state == "SUBMITTED")\ | ||
| 136 | + .all() | ||
| 137 | + | ||
| 138 | + logger.info('Correcting %d tests...', len(dbtests)) | ||
| 139 | + for dbtest in dbtests: | ||
| 140 | + try: | ||
| 141 | + with open(dbtest.filename) as file: | ||
| 142 | + testdict = json.load(file) | ||
| 143 | + except FileNotFoundError: | ||
| 144 | + logger.error('File not found: %s', dbtest.filename) | ||
| 145 | + continue | ||
| 146 | + | ||
| 147 | + # creates a class Test with the methods to correct it | ||
| 148 | + # the questions are still dictionaries, so we have to call | ||
| 149 | + # question_from() to produce Question() instances that can be | ||
| 150 | + # corrected. Finally the test can be corrected. | ||
| 151 | + test = perguntations.test.Test(testdict) | ||
| 152 | + test['questions'] = [question_from(q) for q in test['questions']] | ||
| 153 | + test.correct() | ||
| 154 | + logger.info('Student %s: grade = %f', test['student']['number'], test['grade']) | ||
| 155 | + | ||
| 156 | + # save JSON file (overwriting the old one) | ||
| 157 | + uid = test['student']['number'] | ||
| 158 | + ref = test['ref'] | ||
| 159 | + finish_time = test['finish_time'] | ||
| 160 | + answers_dir = test['answers_dir'] | ||
| 161 | + fname = f'{uid}--{ref}--{finish_time}.json' | ||
| 162 | + fpath = path.join(answers_dir, fname) | ||
| 163 | + test.save_json(fpath) | ||
| 164 | + logger.info('%s saved JSON file.', uid) | ||
| 165 | + | ||
| 166 | + # update database | ||
| 167 | + dbtest.grade = test['grade'] | ||
| 168 | + dbtest.state = test['state'] | ||
| 169 | + dbtest.questions = [ | ||
| 170 | + Question( | ||
| 171 | + number=n, | ||
| 172 | + ref=q['ref'], | ||
| 173 | + grade=q['grade'], | ||
| 174 | + comment=q.get('comment', ''), | ||
| 175 | + starttime=str(test['start_time']), | ||
| 176 | + finishtime=str(test['finish_time']), | ||
| 177 | + test_id=test['ref'] | ||
| 178 | + ) | ||
| 179 | + for n, q in enumerate(test['questions']) | ||
| 180 | + ] | ||
| 181 | + logger.info('%s database updated.', uid) | ||
| 182 | + | ||
| 125 | # ------------------------------------------------------------------------ | 183 | # ------------------------------------------------------------------------ |
| 126 | - async def login(self, uid, try_pw): | 184 | + async def login(self, uid, try_pw, headers=None): |
| 127 | '''login authentication''' | 185 | '''login authentication''' |
| 128 | if uid not in self.allowed and uid != '0': # not allowed | 186 | if uid not in self.allowed and uid != '0': # not allowed |
| 129 | - logger.warning('"%s" not allowed to login.', uid) | ||
| 130 | - return False | 187 | + logger.warning('"%s" unauthorized.', uid) |
| 188 | + return 'unauthorized' | ||
| 131 | 189 | ||
| 132 | - # get name+password from db | ||
| 133 | with self._db_session() as sess: | 190 | with self._db_session() as sess: |
| 134 | - name, password = sess.query(Student.name, Student.password)\ | 191 | + name, hashed_pw = sess.query(Student.name, Student.password)\ |
| 135 | .filter_by(id=uid)\ | 192 | .filter_by(id=uid)\ |
| 136 | .one() | 193 | .one() |
| 137 | 194 | ||
| 138 | - # first login updates the password | ||
| 139 | - if password == '': # update password on first login | 195 | + if hashed_pw == '': # update password on first login |
| 140 | await self.update_student_password(uid, try_pw) | 196 | await self.update_student_password(uid, try_pw) |
| 141 | pw_ok = True | 197 | pw_ok = True |
| 142 | else: # check password | 198 | else: # check password |
| 143 | - pw_ok = await check_password(try_pw, password) # async bcrypt | ||
| 144 | - | ||
| 145 | - if pw_ok: # success | ||
| 146 | - self.allowed.discard(uid) # remove from set of allowed students | ||
| 147 | - if uid in self.online: | ||
| 148 | - logger.warning('"%s" already logged in.', uid) | ||
| 149 | - else: # make student online | ||
| 150 | - self.online[uid] = {'student': {'name': name, 'number': uid}} | ||
| 151 | - logger.info('"%s" logged in.', uid) | ||
| 152 | - return True | ||
| 153 | - # wrong password | ||
| 154 | - logger.info('"%s" wrong password.', uid) | ||
| 155 | - return False | 199 | + pw_ok = await check_password(try_pw, hashed_pw) # async bcrypt |
| 200 | + | ||
| 201 | + if not pw_ok: # wrong password | ||
| 202 | + logger.info('"%s" wrong password.', uid) | ||
| 203 | + return 'wrong_password' | ||
| 204 | + | ||
| 205 | + # success | ||
| 206 | + self.allowed.discard(uid) # remove from set of allowed students | ||
| 207 | + | ||
| 208 | + if uid in self.online: | ||
| 209 | + logger.warning('"%s" login again from %s (reusing state).', | ||
| 210 | + uid, headers['remote_ip']) | ||
| 211 | + # FIXME invalidate previous login | ||
| 212 | + else: | ||
| 213 | + self.online[uid] = {'student': { | ||
| 214 | + 'name': name, | ||
| 215 | + 'number': uid, | ||
| 216 | + 'headers': headers}} | ||
| 217 | + logger.info('"%s" login from %s.', uid, headers['remote_ip']) | ||
| 156 | 218 | ||
| 157 | # ------------------------------------------------------------------------ | 219 | # ------------------------------------------------------------------------ |
| 158 | def logout(self, uid): | 220 | def logout(self, uid): |
| @@ -179,31 +241,48 @@ class App(): | @@ -179,31 +241,48 @@ class App(): | ||
| 179 | testconf.update(conf) | 241 | testconf.update(conf) |
| 180 | 242 | ||
| 181 | # start test factory | 243 | # start test factory |
| 182 | - logger.info('Making test factory...') | 244 | + logger.info('Running test factory...') |
| 183 | try: | 245 | try: |
| 184 | self.testfactory = TestFactory(testconf) | 246 | self.testfactory = TestFactory(testconf) |
| 185 | except TestFactoryException as exc: | 247 | except TestFactoryException as exc: |
| 186 | logger.critical(exc) | 248 | logger.critical(exc) |
| 187 | - raise AppException('Failed to create test factory!') from exc | ||
| 188 | - | ||
| 189 | - logger.info('Test factory ready. No errors found.') | 249 | + raise AppException('Failed to create test factory!') from exc |
| 190 | 250 | ||
| 191 | # ------------------------------------------------------------------------ | 251 | # ------------------------------------------------------------------------ |
| 192 | - def _pregenerate_tests(self, num): | 252 | + def _pregenerate_tests(self, num): # TODO needs improvement |
| 193 | event_loop = asyncio.get_event_loop() | 253 | event_loop = asyncio.get_event_loop() |
| 194 | - # for _ in range(num): | ||
| 195 | - # test = event_loop.run_until_complete(self.testfactory.generate()) | ||
| 196 | - # self.pregenerated_tests.append(test) | ||
| 197 | - | ||
| 198 | self.pregenerated_tests += [ | 254 | self.pregenerated_tests += [ |
| 199 | event_loop.run_until_complete(self.testfactory.generate()) | 255 | event_loop.run_until_complete(self.testfactory.generate()) |
| 200 | for _ in range(num)] | 256 | for _ in range(num)] |
| 201 | 257 | ||
| 202 | # ------------------------------------------------------------------------ | 258 | # ------------------------------------------------------------------------ |
| 203 | - async def generate_test(self, uid): | ||
| 204 | - '''generate a test for a given student. the student must be online''' | 259 | + async def get_test_or_generate(self, uid): |
| 260 | + '''get current test or generate a new one''' | ||
| 261 | + try: | ||
| 262 | + student = self.online[uid] | ||
| 263 | + except KeyError as exc: | ||
| 264 | + msg = f'"{uid}" is not online. get_test_or_generate() FAILED' | ||
| 265 | + logger.error(msg) | ||
| 266 | + raise AppException(msg) from exc | ||
| 267 | + | ||
| 268 | + # get current test. if test does not exist then generate a new one | ||
| 269 | + if not 'test' in student: | ||
| 270 | + await self._new_test(uid) | ||
| 271 | + | ||
| 272 | + return student['test'] | ||
| 273 | + | ||
| 274 | + def get_test(self, uid): | ||
| 275 | + '''get test from online student or raise exception''' | ||
| 276 | + return self.online[uid]['test'] | ||
| 205 | 277 | ||
| 206 | - student_id = self.online[uid]['student'] # {'name': ?, 'number': ?} | 278 | + # ------------------------------------------------------------------------ |
| 279 | + async def _new_test(self, uid): | ||
| 280 | + ''' | ||
| 281 | + assign a test to a given student. if there are pregenerated tests then | ||
| 282 | + use one of them, otherwise generate one. | ||
| 283 | + the student must be online | ||
| 284 | + ''' | ||
| 285 | + student = self.online[uid]['student'] # {'name': ?, 'number': ?} | ||
| 207 | 286 | ||
| 208 | try: | 287 | try: |
| 209 | test = self.pregenerated_tests.pop() | 288 | test = self.pregenerated_tests.pop() |
| @@ -214,15 +293,13 @@ class App(): | @@ -214,15 +293,13 @@ class App(): | ||
| 214 | else: | 293 | else: |
| 215 | logger.info('"%s" using a pregenerated test.', uid) | 294 | logger.info('"%s" using a pregenerated test.', uid) |
| 216 | 295 | ||
| 217 | - test.start(student_id) # student signs the test | ||
| 218 | - self.online[uid]['test'] = test # register test for this student | ||
| 219 | - | ||
| 220 | - return self.online[uid]['test'] | 296 | + test.start(student) # student signs the test |
| 297 | + self.online[uid]['test'] = test | ||
| 221 | 298 | ||
| 222 | # ------------------------------------------------------------------------ | 299 | # ------------------------------------------------------------------------ |
| 223 | - async def correct_test(self, uid, ans): | 300 | + async def submit_test(self, uid, ans): |
| 224 | ''' | 301 | ''' |
| 225 | - Corrects test | 302 | + Handles test submission and correction. |
| 226 | 303 | ||
| 227 | ans is a dictionary {question_index: answer, ...} with the answers for | 304 | ans is a dictionary {question_index: answer, ...} with the answers for |
| 228 | the complete test. For example: {0:'hello', 1:[1,2]} | 305 | the complete test. For example: {0:'hello', 1:[1,2]} |
| @@ -230,72 +307,81 @@ class App(): | @@ -230,72 +307,81 @@ class App(): | ||
| 230 | test = self.online[uid]['test'] | 307 | test = self.online[uid]['test'] |
| 231 | 308 | ||
| 232 | # --- submit answers and correct test | 309 | # --- submit answers and correct test |
| 233 | - test.update_answers(ans) | 310 | + test.submit(ans) |
| 234 | logger.info('"%s" submitted %d answers.', uid, len(ans)) | 311 | logger.info('"%s" submitted %d answers.', uid, len(ans)) |
| 235 | 312 | ||
| 236 | - grade = await test.correct() | ||
| 237 | - logger.info('"%s" grade = %g points.', uid, grade) | 313 | + if test['autocorrect']: |
| 314 | + await test.correct_async() | ||
| 315 | + logger.info('"%s" grade = %g points.', uid, test['grade']) | ||
| 238 | 316 | ||
| 239 | # --- save test in JSON format | 317 | # --- save test in JSON format |
| 240 | - fields = (uid, test['ref'], str(test['finish_time'])) | ||
| 241 | - fname = '--'.join(fields) + '.json' | 318 | + fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json' |
| 242 | fpath = path.join(test['answers_dir'], fname) | 319 | fpath = path.join(test['answers_dir'], fname) |
| 243 | - with open(path.expanduser(fpath), 'w') as file: | ||
| 244 | - # default=str required for datetime objects | ||
| 245 | - json.dump(test, file, indent=2, default=str) | 320 | + test.save_json(fpath) |
| 246 | logger.info('"%s" saved JSON.', uid) | 321 | logger.info('"%s" saved JSON.', uid) |
| 247 | 322 | ||
| 248 | - # --- insert test and questions into database | ||
| 249 | - with self._db_session() as sess: | ||
| 250 | - sess.add(Test( | ||
| 251 | - ref=test['ref'], | ||
| 252 | - title=test['title'], | ||
| 253 | - grade=test['grade'], | ||
| 254 | - starttime=str(test['start_time']), | ||
| 255 | - finishtime=str(test['finish_time']), | ||
| 256 | - filename=fpath, | ||
| 257 | - student_id=uid, | ||
| 258 | - state=test['state'], | ||
| 259 | - comment='')) | ||
| 260 | - sess.add_all([Question( | ||
| 261 | - ref=q['ref'], | ||
| 262 | - grade=q['grade'], | ||
| 263 | - starttime=str(test['start_time']), | ||
| 264 | - finishtime=str(test['finish_time']), | ||
| 265 | - student_id=uid, | ||
| 266 | - test_id=test['ref']) | ||
| 267 | - for q in test['questions'] if 'grade' in q]) | 323 | + # --- insert test and questions into the database |
| 324 | + # only corrected questions are added | ||
| 325 | + test_row = Test( | ||
| 326 | + ref=test['ref'], | ||
| 327 | + title=test['title'], | ||
| 328 | + grade=test['grade'], | ||
| 329 | + state=test['state'], | ||
| 330 | + comment=test['comment'], | ||
| 331 | + starttime=str(test['start_time']), | ||
| 332 | + finishtime=str(test['finish_time']), | ||
| 333 | + filename=fpath, | ||
| 334 | + student_id=uid) | ||
| 335 | + | ||
| 336 | + if test['state'] == 'CORRECTED': | ||
| 337 | + test_row.questions = [ | ||
| 338 | + Question( | ||
| 339 | + number=n, | ||
| 340 | + ref=q['ref'], | ||
| 341 | + grade=q['grade'], | ||
| 342 | + comment=q.get('comment', ''), | ||
| 343 | + starttime=str(test['start_time']), | ||
| 344 | + finishtime=str(test['finish_time']), | ||
| 345 | + test_id=test['ref'] | ||
| 346 | + ) | ||
| 347 | + for n, q in enumerate(test['questions']) | ||
| 348 | + ] | ||
| 268 | 349 | ||
| 350 | + with self._db_session() as sess: | ||
| 351 | + sess.add(test_row) | ||
| 269 | logger.info('"%s" database updated.', uid) | 352 | logger.info('"%s" database updated.', uid) |
| 270 | - return grade | ||
| 271 | 353 | ||
| 272 | # ------------------------------------------------------------------------ | 354 | # ------------------------------------------------------------------------ |
| 273 | - def giveup_test(self, uid): | ||
| 274 | - '''giveup test - not used??''' | ||
| 275 | - test = self.online[uid]['test'] | ||
| 276 | - test.giveup() | 355 | + def get_student_grade(self, uid): |
| 356 | + return self.online[uid]['test'].get('grade', None) | ||
| 277 | 357 | ||
| 278 | - # save JSON with the test | ||
| 279 | - fields = (test['student']['number'], test['ref'], | ||
| 280 | - str(test['finish_time'])) | ||
| 281 | - fname = '--'.join(fields) + '.json' | ||
| 282 | - fpath = path.join(test['answers_dir'], fname) | ||
| 283 | - test.save_json(fpath) | ||
| 284 | - | ||
| 285 | - # insert test into database | ||
| 286 | - with self._db_session() as sess: | ||
| 287 | - sess.add(Test(ref=test['ref'], | ||
| 288 | - title=test['title'], | ||
| 289 | - grade=test['grade'], | ||
| 290 | - starttime=str(test['start_time']), | ||
| 291 | - finishtime=str(test['finish_time']), | ||
| 292 | - filename=fpath, | ||
| 293 | - student_id=test['student']['number'], | ||
| 294 | - state=test['state'], | ||
| 295 | - comment='')) | ||
| 296 | - | ||
| 297 | - logger.info('"%s" gave up.', uid) | ||
| 298 | - return test | 358 | + # ------------------------------------------------------------------------ |
| 359 | + # def giveup_test(self, uid): | ||
| 360 | + # '''giveup test - not used??''' | ||
| 361 | + # test = self.online[uid]['test'] | ||
| 362 | + # test.giveup() | ||
| 363 | + | ||
| 364 | + # # save JSON with the test | ||
| 365 | + # fields = (test['student']['number'], test['ref'], | ||
| 366 | + # str(test['finish_time'])) | ||
| 367 | + # fname = '--'.join(fields) + '.json' | ||
| 368 | + # fpath = path.join(test['answers_dir'], fname) | ||
| 369 | + # test.save_json(fpath) | ||
| 370 | + | ||
| 371 | + # # insert test into database | ||
| 372 | + # with self._db_session() as sess: | ||
| 373 | + # sess.add(Test(ref=test['ref'], | ||
| 374 | + # title=test['title'], | ||
| 375 | + # grade=test['grade'], | ||
| 376 | + # starttime=str(test['start_time']), | ||
| 377 | + # finishtime=str(test['finish_time']), | ||
| 378 | + # filename=fpath, | ||
| 379 | + # # student_id=test['student']['number'], | ||
| 380 | + # state=test['state'], | ||
| 381 | + # comment='')) | ||
| 382 | + | ||
| 383 | + # logger.info('"%s" gave up.', uid) | ||
| 384 | + # return test | ||
| 299 | 385 | ||
| 300 | # ------------------------------------------------------------------------ | 386 | # ------------------------------------------------------------------------ |
| 301 | def event_test(self, uid, cmd, value): | 387 | def event_test(self, uid, cmd, value): |
| @@ -317,57 +403,58 @@ class App(): | @@ -317,57 +403,58 @@ class App(): | ||
| 317 | 403 | ||
| 318 | def get_questions_csv(self): | 404 | def get_questions_csv(self): |
| 319 | '''generates a CSV with the grades of the test''' | 405 | '''generates a CSV with the grades of the test''' |
| 320 | - test_id = self.testfactory['ref'] | ||
| 321 | - | 406 | + test_ref = self.testfactory['ref'] |
| 322 | with self._db_session() as sess: | 407 | with self._db_session() as sess: |
| 323 | - grades = sess.query(Question.student_id, Question.starttime, | ||
| 324 | - Question.ref, Question.grade)\ | ||
| 325 | - .filter(Question.test_id == test_id)\ | ||
| 326 | - .order_by(Question.student_id)\ | ||
| 327 | - .all() | ||
| 328 | - | ||
| 329 | - cols = ['Aluno', 'Início'] + \ | ||
| 330 | - [r for question in self.testfactory['questions'] | ||
| 331 | - for r in question['ref']] | ||
| 332 | - | ||
| 333 | - tests = {} | ||
| 334 | - for question in grades: | ||
| 335 | - student, qref, qgrade = question[:2], *question[2:] | ||
| 336 | - tests.setdefault(student, {})[qref] = qgrade | ||
| 337 | - | ||
| 338 | - rows = [{'Aluno': test[0], 'Início': test[1], **q} | ||
| 339 | - for test, q in tests.items()] | 408 | + questions = sess.query(Test.id, Test.student_id, Test.starttime, |
| 409 | + Question.number, Question.grade)\ | ||
| 410 | + .join(Question)\ | ||
| 411 | + .filter(Test.ref == test_ref)\ | ||
| 412 | + .all() | ||
| 413 | + | ||
| 414 | + qnums = set() # keeps track of all the questions in the test | ||
| 415 | + tests = {} # {test_id: {student_id, starttime, 0: grade, ...}} | ||
| 416 | + for question in questions: | ||
| 417 | + test_id, student_id, starttime, num, grade = question | ||
| 418 | + default_test_id = {'Aluno': student_id, 'Início': starttime} | ||
| 419 | + tests.setdefault(test_id, default_test_id)[num] = grade | ||
| 420 | + qnums.add(num) | ||
| 421 | + | ||
| 422 | + if not tests: | ||
| 423 | + logger.warning('Empty CSV: there are no tests!') | ||
| 424 | + return test_ref, '' | ||
| 425 | + | ||
| 426 | + cols = ['Aluno', 'Início'] + list(qnums) | ||
| 340 | 427 | ||
| 341 | csvstr = io.StringIO() | 428 | csvstr = io.StringIO() |
| 342 | writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, | 429 | writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, |
| 343 | delimiter=';', quoting=csv.QUOTE_ALL) | 430 | delimiter=';', quoting=csv.QUOTE_ALL) |
| 344 | writer.writeheader() | 431 | writer.writeheader() |
| 345 | - writer.writerows(rows) | ||
| 346 | - return test_id, csvstr.getvalue() | ||
| 347 | - | 432 | + writer.writerows(tests.values()) |
| 433 | + return test_ref, csvstr.getvalue() | ||
| 348 | 434 | ||
| 349 | def get_test_csv(self): | 435 | def get_test_csv(self): |
| 350 | - '''generates a CSV with the grades of the test''' | 436 | + '''generates a CSV with the grades of the test currently running''' |
| 437 | + test_ref = self.testfactory['ref'] | ||
| 351 | with self._db_session() as sess: | 438 | with self._db_session() as sess: |
| 352 | - grades = sess.query(Test.student_id, Test.grade, | 439 | + tests = sess.query(Test.student_id, |
| 440 | + Test.grade, | ||
| 353 | Test.starttime, Test.finishtime)\ | 441 | Test.starttime, Test.finishtime)\ |
| 354 | - .filter(Test.ref == self.testfactory['ref'])\ | 442 | + .filter(Test.ref == test_ref)\ |
| 355 | .order_by(Test.student_id)\ | 443 | .order_by(Test.student_id)\ |
| 356 | .all() | 444 | .all() |
| 357 | 445 | ||
| 446 | + if not tests: | ||
| 447 | + logger.warning('Empty CSV: there are no tests!') | ||
| 448 | + return test_ref, '' | ||
| 449 | + | ||
| 358 | csvstr = io.StringIO() | 450 | csvstr = io.StringIO() |
| 359 | writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) | 451 | writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) |
| 360 | writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) | 452 | writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) |
| 361 | - writer.writerows(grades) | ||
| 362 | - return self.testfactory['ref'], csvstr.getvalue() | 453 | + writer.writerows(tests) |
| 363 | 454 | ||
| 364 | - def get_student_test(self, uid): | ||
| 365 | - '''get test from online student or None if no test was generated yet''' | ||
| 366 | - return self.online[uid].get('test', None) | ||
| 367 | - | ||
| 368 | - # def get_questions_dir(self): | ||
| 369 | - # return self.testfactory['questions_dir'] | 455 | + return test_ref, csvstr.getvalue() |
| 370 | 456 | ||
| 457 | + # ------------------------------------------------------------------------ | ||
| 371 | def get_student_grades_from_all_tests(self, uid): | 458 | def get_student_grades_from_all_tests(self, uid): |
| 372 | '''get grades of student from all tests''' | 459 | '''get grades of student from all tests''' |
| 373 | with self._db_session() as sess: | 460 | with self._db_session() as sess: |
perguntations/initdb.py
| @@ -19,7 +19,7 @@ import sqlalchemy as sa | @@ -19,7 +19,7 @@ import sqlalchemy as sa | ||
| 19 | from perguntations.models import Base, Student | 19 | from perguntations.models import Base, Student |
| 20 | 20 | ||
| 21 | 21 | ||
| 22 | -# =========================================================================== | 22 | +# ============================================================================ |
| 23 | def parse_commandline_arguments(): | 23 | def parse_commandline_arguments(): |
| 24 | '''Parse command line options''' | 24 | '''Parse command line options''' |
| 25 | parser = argparse.ArgumentParser( | 25 | parser = argparse.ArgumentParser( |
| @@ -68,7 +68,7 @@ def parse_commandline_arguments(): | @@ -68,7 +68,7 @@ def parse_commandline_arguments(): | ||
| 68 | return parser.parse_args() | 68 | return parser.parse_args() |
| 69 | 69 | ||
| 70 | 70 | ||
| 71 | -# =========================================================================== | 71 | +# ============================================================================ |
| 72 | def get_students_from_csv(filename): | 72 | def get_students_from_csv(filename): |
| 73 | ''' | 73 | ''' |
| 74 | SIIUE names have alien strings like "(TE)" and are sometimes capitalized | 74 | SIIUE names have alien strings like "(TE)" and are sometimes capitalized |
| @@ -97,7 +97,7 @@ def get_students_from_csv(filename): | @@ -97,7 +97,7 @@ def get_students_from_csv(filename): | ||
| 97 | return students | 97 | return students |
| 98 | 98 | ||
| 99 | 99 | ||
| 100 | -# =========================================================================== | 100 | +# ============================================================================ |
| 101 | def hashpw(student, password=None): | 101 | def hashpw(student, password=None): |
| 102 | '''replace password by hash for a single student''' | 102 | '''replace password by hash for a single student''' |
| 103 | print('.', end='', flush=True) | 103 | print('.', end='', flush=True) |
| @@ -108,7 +108,7 @@ def hashpw(student, password=None): | @@ -108,7 +108,7 @@ def hashpw(student, password=None): | ||
| 108 | bcrypt.gensalt()) | 108 | bcrypt.gensalt()) |
| 109 | 109 | ||
| 110 | 110 | ||
| 111 | -# =========================================================================== | 111 | +# ============================================================================ |
| 112 | def insert_students_into_db(session, students): | 112 | def insert_students_into_db(session, students): |
| 113 | '''insert list of students into the database''' | 113 | '''insert list of students into the database''' |
| 114 | try: | 114 | try: |
perguntations/main.py
| @@ -49,13 +49,16 @@ def parse_cmdline_arguments(): | @@ -49,13 +49,16 @@ def parse_cmdline_arguments(): | ||
| 49 | parser.add_argument('--review', | 49 | parser.add_argument('--review', |
| 50 | action='store_true', | 50 | action='store_true', |
| 51 | help='Review mode: doesn\'t generate test') | 51 | help='Review mode: doesn\'t generate test') |
| 52 | + parser.add_argument('--correct', | ||
| 53 | + action='store_true', | ||
| 54 | + help='Correct test and update JSON files and database') | ||
| 52 | parser.add_argument('--port', | 55 | parser.add_argument('--port', |
| 53 | type=int, | 56 | type=int, |
| 54 | default=8443, | 57 | default=8443, |
| 55 | help='port for the HTTPS server (default: 8443)') | 58 | help='port for the HTTPS server (default: 8443)') |
| 56 | parser.add_argument('--version', | 59 | parser.add_argument('--version', |
| 57 | action='version', | 60 | action='version', |
| 58 | - version=APP_VERSION, | 61 | + version=f'{APP_VERSION} - python {sys.version}', |
| 59 | help='Show version information and exit') | 62 | help='Show version information and exit') |
| 60 | return parser.parse_args() | 63 | return parser.parse_args() |
| 61 | 64 | ||
| @@ -99,13 +102,12 @@ def get_logger_config(debug=False): | @@ -99,13 +102,12 @@ def get_logger_config(debug=False): | ||
| 99 | }, | 102 | }, |
| 100 | }, | 103 | }, |
| 101 | } | 104 | } |
| 102 | - default_config['loggers'].update({ | ||
| 103 | - APP_NAME+'.'+module: { | ||
| 104 | - 'handlers': ['default'], | ||
| 105 | - 'level': level, | ||
| 106 | - 'propagate': False, | ||
| 107 | - } for module in ['app', 'models', 'factory', 'questions', | ||
| 108 | - 'test', 'tools']}) | 105 | + |
| 106 | + modules = ['app', 'models', 'questions', 'test', 'testfactory', 'tools'] | ||
| 107 | + logger = {'handlers': ['default'], 'level': level, 'propagate': False} | ||
| 108 | + | ||
| 109 | + default_config['loggers'].update({f'{APP_NAME}.{module}': logger | ||
| 110 | + for module in modules}) | ||
| 109 | 111 | ||
| 110 | return load_yaml(config_file, default=default_config) | 112 | return load_yaml(config_file, default=default_config) |
| 111 | 113 | ||
| @@ -124,11 +126,12 @@ def main(): | @@ -124,11 +126,12 @@ def main(): | ||
| 124 | # --- start application -------------------------------------------------- | 126 | # --- start application -------------------------------------------------- |
| 125 | config = { | 127 | config = { |
| 126 | 'testfile': args.testfile, | 128 | 'testfile': args.testfile, |
| 127 | - 'debug': args.debug, | 129 | + 'debug': args.debug, |
| 128 | 'allow_all': args.allow_all, | 130 | 'allow_all': args.allow_all, |
| 129 | 'allow_list': args.allow_list, | 131 | 'allow_list': args.allow_list, |
| 130 | 'show_ref': args.show_ref, | 132 | 'show_ref': args.show_ref, |
| 131 | - 'review': args.review, | 133 | + 'review': args.review, |
| 134 | + 'correct': args.correct, | ||
| 132 | } | 135 | } |
| 133 | 136 | ||
| 134 | try: | 137 | try: |
perguntations/models.py
| @@ -25,9 +25,8 @@ class Student(Base): | @@ -25,9 +25,8 @@ class Student(Base): | ||
| 25 | 25 | ||
| 26 | # --- | 26 | # --- |
| 27 | tests = relationship('Test', back_populates='student') | 27 | tests = relationship('Test', back_populates='student') |
| 28 | - questions = relationship('Question', back_populates='student') | ||
| 29 | 28 | ||
| 30 | - def __repr__(self): | 29 | + def __str__(self): |
| 31 | return (f'Student:\n' | 30 | return (f'Student:\n' |
| 32 | f' id: "{self.id}"\n' | 31 | f' id: "{self.id}"\n' |
| 33 | f' name: "{self.name}"\n' | 32 | f' name: "{self.name}"\n' |
| @@ -42,7 +41,7 @@ class Test(Base): | @@ -42,7 +41,7 @@ class Test(Base): | ||
| 42 | ref = Column(String) | 41 | ref = Column(String) |
| 43 | title = Column(String) | 42 | title = Column(String) |
| 44 | grade = Column(Float) | 43 | grade = Column(Float) |
| 45 | - state = Column(String) # ACTIVE, FINISHED, QUIT, NULL | 44 | + state = Column(String) # ACTIVE, SUBMITTED, CORRECTED, QUIT, NULL |
| 46 | comment = Column(String) | 45 | comment = Column(String) |
| 47 | starttime = Column(String) | 46 | starttime = Column(String) |
| 48 | finishtime = Column(String) | 47 | finishtime = Column(String) |
| @@ -53,12 +52,12 @@ class Test(Base): | @@ -53,12 +52,12 @@ class Test(Base): | ||
| 53 | student = relationship('Student', back_populates='tests') | 52 | student = relationship('Student', back_populates='tests') |
| 54 | questions = relationship('Question', back_populates='test') | 53 | questions = relationship('Question', back_populates='test') |
| 55 | 54 | ||
| 56 | - def __repr__(self): | 55 | + def __str__(self): |
| 57 | return (f'Test:\n' | 56 | return (f'Test:\n' |
| 58 | - f' id: "{self.id}"\n' | 57 | + f' id: {self.id}\n' |
| 59 | f' ref: "{self.ref}"\n' | 58 | f' ref: "{self.ref}"\n' |
| 60 | f' title: "{self.title}"\n' | 59 | f' title: "{self.title}"\n' |
| 61 | - f' grade: "{self.grade}"\n' | 60 | + f' grade: {self.grade}\n' |
| 62 | f' state: "{self.state}"\n' | 61 | f' state: "{self.state}"\n' |
| 63 | f' comment: "{self.comment}"\n' | 62 | f' comment: "{self.comment}"\n' |
| 64 | f' starttime: "{self.starttime}"\n' | 63 | f' starttime: "{self.starttime}"\n' |
| @@ -72,23 +71,24 @@ class Question(Base): | @@ -72,23 +71,24 @@ class Question(Base): | ||
| 72 | '''Question table''' | 71 | '''Question table''' |
| 73 | __tablename__ = 'questions' | 72 | __tablename__ = 'questions' |
| 74 | id = Column(Integer, primary_key=True) # auto_increment | 73 | id = Column(Integer, primary_key=True) # auto_increment |
| 74 | + number = Column(Integer) # question number (ref may be not be unique) | ||
| 75 | ref = Column(String) | 75 | ref = Column(String) |
| 76 | grade = Column(Float) | 76 | grade = Column(Float) |
| 77 | + comment = Column(String) | ||
| 77 | starttime = Column(String) | 78 | starttime = Column(String) |
| 78 | finishtime = Column(String) | 79 | finishtime = Column(String) |
| 79 | - student_id = Column(String, ForeignKey('students.id')) | ||
| 80 | test_id = Column(String, ForeignKey('tests.id')) | 80 | test_id = Column(String, ForeignKey('tests.id')) |
| 81 | 81 | ||
| 82 | # --- | 82 | # --- |
| 83 | - student = relationship('Student', back_populates='questions') | ||
| 84 | test = relationship('Test', back_populates='questions') | 83 | test = relationship('Test', back_populates='questions') |
| 85 | 84 | ||
| 86 | - def __repr__(self): | 85 | + def __str__(self): |
| 87 | return (f'Question:\n' | 86 | return (f'Question:\n' |
| 88 | - f' id: "{self.id}"\n' | 87 | + f' id: {self.id}\n' |
| 88 | + f' number: {self.number}\n' | ||
| 89 | f' ref: "{self.ref}"\n' | 89 | f' ref: "{self.ref}"\n' |
| 90 | - f' grade: "{self.grade}"\n' | 90 | + f' grade: {self.grade}\n' |
| 91 | + f' comment: "{self.comment}"\n' | ||
| 91 | f' starttime: "{self.starttime}"\n' | 92 | f' starttime: "{self.starttime}"\n' |
| 92 | f' finishtime: "{self.finishtime}"\n' | 93 | f' finishtime: "{self.finishtime}"\n' |
| 93 | - f' student_id: "{self.student_id}"\n' # FIXME normal form | ||
| 94 | f' test_id: "{self.test_id}"\n') | 94 | f' test_id: "{self.test_id}"\n') |
perguntations/questions.py
| @@ -13,6 +13,12 @@ import re | @@ -13,6 +13,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 | + | ||
| 16 | # this project | 22 | # this project |
| 17 | from perguntations.tools import run_script, run_script_async | 23 | from perguntations.tools import run_script, run_script_async |
| 18 | 24 | ||
| @@ -23,6 +29,8 @@ logger = logging.getLogger(__name__) | @@ -23,6 +29,8 @@ logger = logging.getLogger(__name__) | ||
| 23 | QDict = NewType('QDict', Dict[str, Any]) | 29 | QDict = NewType('QDict', Dict[str, Any]) |
| 24 | 30 | ||
| 25 | 31 | ||
| 32 | + | ||
| 33 | + | ||
| 26 | class QuestionException(Exception): | 34 | class QuestionException(Exception): |
| 27 | '''Exceptions raised in this module''' | 35 | '''Exceptions raised in this module''' |
| 28 | 36 | ||
| @@ -37,8 +45,13 @@ class Question(dict): | @@ -37,8 +45,13 @@ class Question(dict): | ||
| 37 | for each student. | 45 | for each student. |
| 38 | Instances can shuffle options or automatically generate questions. | 46 | Instances can shuffle options or automatically generate questions. |
| 39 | ''' | 47 | ''' |
| 40 | - def __init__(self, q: QDict) -> None: | ||
| 41 | - super().__init__(q) | 48 | + # def __init__(self, q: QDict) -> None: |
| 49 | + # super().__init__(q) | ||
| 50 | + | ||
| 51 | + def gen(self) -> None: | ||
| 52 | + ''' | ||
| 53 | + Sets defaults that are valid for any question type | ||
| 54 | + ''' | ||
| 42 | 55 | ||
| 43 | # add required keys if missing | 56 | # add required keys if missing |
| 44 | self.set_defaults(QDict({ | 57 | self.set_defaults(QDict({ |
| @@ -83,9 +96,15 @@ class QuestionRadio(Question): | @@ -83,9 +96,15 @@ class QuestionRadio(Question): | ||
| 83 | ''' | 96 | ''' |
| 84 | 97 | ||
| 85 | # ------------------------------------------------------------------------ | 98 | # ------------------------------------------------------------------------ |
| 86 | - def __init__(self, q: QDict) -> None: | ||
| 87 | - super().__init__(q) | 99 | + # def __init__(self, q: QDict) -> None: |
| 100 | + # super().__init__(q) | ||
| 88 | 101 | ||
| 102 | + def gen(self) -> None: | ||
| 103 | + ''' | ||
| 104 | + Sets defaults, performs checks and generates the actual question | ||
| 105 | + by modifying the options and correct values | ||
| 106 | + ''' | ||
| 107 | + super().gen() | ||
| 89 | try: | 108 | try: |
| 90 | nopts = len(self['options']) | 109 | nopts = len(self['options']) |
| 91 | except KeyError as exc: | 110 | except KeyError as exc: |
| @@ -212,8 +231,11 @@ class QuestionCheckbox(Question): | @@ -212,8 +231,11 @@ class QuestionCheckbox(Question): | ||
| 212 | ''' | 231 | ''' |
| 213 | 232 | ||
| 214 | # ------------------------------------------------------------------------ | 233 | # ------------------------------------------------------------------------ |
| 215 | - def __init__(self, q: QDict) -> None: | ||
| 216 | - super().__init__(q) | 234 | + # def __init__(self, q: QDict) -> None: |
| 235 | + # super().__init__(q) | ||
| 236 | + | ||
| 237 | + def gen(self) -> None: | ||
| 238 | + super().gen() | ||
| 217 | 239 | ||
| 218 | try: | 240 | try: |
| 219 | nopts = len(self['options']) | 241 | nopts = len(self['options']) |
| @@ -334,9 +356,11 @@ class QuestionText(Question): | @@ -334,9 +356,11 @@ class QuestionText(Question): | ||
| 334 | ''' | 356 | ''' |
| 335 | 357 | ||
| 336 | # ------------------------------------------------------------------------ | 358 | # ------------------------------------------------------------------------ |
| 337 | - def __init__(self, q: QDict) -> None: | ||
| 338 | - super().__init__(q) | 359 | + # def __init__(self, q: QDict) -> None: |
| 360 | + # super().__init__(q) | ||
| 339 | 361 | ||
| 362 | + def gen(self) -> None: | ||
| 363 | + super().gen() | ||
| 340 | self.set_defaults(QDict({ | 364 | self.set_defaults(QDict({ |
| 341 | 'text': '', | 365 | 'text': '', |
| 342 | 'correct': [], # no correct answers, always wrong | 366 | 'correct': [], # no correct answers, always wrong |
| @@ -403,8 +427,11 @@ class QuestionTextRegex(Question): | @@ -403,8 +427,11 @@ class QuestionTextRegex(Question): | ||
| 403 | ''' | 427 | ''' |
| 404 | 428 | ||
| 405 | # ------------------------------------------------------------------------ | 429 | # ------------------------------------------------------------------------ |
| 406 | - def __init__(self, q: QDict) -> None: | ||
| 407 | - super().__init__(q) | 430 | + # def __init__(self, q: QDict) -> None: |
| 431 | + # super().__init__(q) | ||
| 432 | + | ||
| 433 | + def gen(self) -> None: | ||
| 434 | + super().gen() | ||
| 408 | 435 | ||
| 409 | self.set_defaults(QDict({ | 436 | self.set_defaults(QDict({ |
| 410 | 'text': '', | 437 | 'text': '', |
| @@ -416,26 +443,34 @@ class QuestionTextRegex(Question): | @@ -416,26 +443,34 @@ class QuestionTextRegex(Question): | ||
| 416 | self['correct'] = [self['correct']] | 443 | self['correct'] = [self['correct']] |
| 417 | 444 | ||
| 418 | # converts patterns to compiled versions | 445 | # converts patterns to compiled versions |
| 419 | - try: | ||
| 420 | - self['correct'] = [re.compile(a) for a in self['correct']] | ||
| 421 | - except Exception as exc: | ||
| 422 | - msg = f'Failed to compile regex in "{self["ref"]}"' | ||
| 423 | - logger.error(msg) | ||
| 424 | - raise QuestionException(msg) from exc | 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 | ||
| 425 | 452 | ||
| 426 | # ------------------------------------------------------------------------ | 453 | # ------------------------------------------------------------------------ |
| 427 | def correct(self) -> None: | 454 | def correct(self) -> None: |
| 428 | super().correct() | 455 | super().correct() |
| 429 | if self['answer'] is not None: | 456 | if self['answer'] is not None: |
| 430 | - self['grade'] = 0.0 | ||
| 431 | for regex in self['correct']: | 457 | for regex in self['correct']: |
| 432 | try: | 458 | try: |
| 433 | - if regex.match(self['answer']): | 459 | + if re.fullmatch(regex, self['answer']): |
| 434 | self['grade'] = 1.0 | 460 | self['grade'] = 1.0 |
| 435 | return | 461 | return |
| 436 | except TypeError: | 462 | except TypeError: |
| 437 | - logger.error('While matching regex %s with answer "%s".', | ||
| 438 | - regex.pattern, self["answer"]) | 463 | + logger.error('While matching regex "%s" with answer "%s".', |
| 464 | + regex, self['answer']) | ||
| 465 | + self['grade'] = 0.0 | ||
| 466 | + | ||
| 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"]) | ||
| 439 | 474 | ||
| 440 | 475 | ||
| 441 | # ============================================================================ | 476 | # ============================================================================ |
| @@ -449,8 +484,11 @@ class QuestionNumericInterval(Question): | @@ -449,8 +484,11 @@ class QuestionNumericInterval(Question): | ||
| 449 | ''' | 484 | ''' |
| 450 | 485 | ||
| 451 | # ------------------------------------------------------------------------ | 486 | # ------------------------------------------------------------------------ |
| 452 | - def __init__(self, q: QDict) -> None: | ||
| 453 | - super().__init__(q) | 487 | + # def __init__(self, q: QDict) -> None: |
| 488 | + # super().__init__(q) | ||
| 489 | + | ||
| 490 | + def gen(self) -> None: | ||
| 491 | + super().gen() | ||
| 454 | 492 | ||
| 455 | self.set_defaults(QDict({ | 493 | self.set_defaults(QDict({ |
| 456 | 'text': '', | 494 | 'text': '', |
| @@ -510,8 +548,11 @@ class QuestionTextArea(Question): | @@ -510,8 +548,11 @@ class QuestionTextArea(Question): | ||
| 510 | ''' | 548 | ''' |
| 511 | 549 | ||
| 512 | # ------------------------------------------------------------------------ | 550 | # ------------------------------------------------------------------------ |
| 513 | - def __init__(self, q: QDict) -> None: | ||
| 514 | - super().__init__(q) | 551 | + # def __init__(self, q: QDict) -> None: |
| 552 | + # super().__init__(q) | ||
| 553 | + | ||
| 554 | + def gen(self) -> None: | ||
| 555 | + super().gen() | ||
| 515 | 556 | ||
| 516 | self.set_defaults(QDict({ | 557 | self.set_defaults(QDict({ |
| 517 | 'text': '', | 558 | 'text': '', |
| @@ -584,6 +625,129 @@ class QuestionTextArea(Question): | @@ -584,6 +625,129 @@ class QuestionTextArea(Question): | ||
| 584 | 625 | ||
| 585 | 626 | ||
| 586 | # ============================================================================ | 627 | # ============================================================================ |
| 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 | +# ============================================================================ | ||
| 587 | class QuestionInformation(Question): | 751 | class QuestionInformation(Question): |
| 588 | ''' | 752 | ''' |
| 589 | Not really a question, just an information panel. | 753 | Not really a question, just an information panel. |
| @@ -591,8 +755,11 @@ class QuestionInformation(Question): | @@ -591,8 +755,11 @@ class QuestionInformation(Question): | ||
| 591 | ''' | 755 | ''' |
| 592 | 756 | ||
| 593 | # ------------------------------------------------------------------------ | 757 | # ------------------------------------------------------------------------ |
| 594 | - def __init__(self, q: QDict) -> None: | ||
| 595 | - super().__init__(q) | 758 | + # def __init__(self, q: QDict) -> None: |
| 759 | + # super().__init__(q) | ||
| 760 | + | ||
| 761 | + def gen(self) -> None: | ||
| 762 | + super().gen() | ||
| 596 | self.set_defaults(QDict({ | 763 | self.set_defaults(QDict({ |
| 597 | 'text': '', | 764 | 'text': '', |
| 598 | })) | 765 | })) |
| @@ -603,6 +770,46 @@ class QuestionInformation(Question): | @@ -603,6 +770,46 @@ class QuestionInformation(Question): | ||
| 603 | self['grade'] = 1.0 # always "correct" but points should be zero! | 770 | self['grade'] = 1.0 # always "correct" but points should be zero! |
| 604 | 771 | ||
| 605 | 772 | ||
| 773 | + | ||
| 774 | +# ============================================================================ | ||
| 775 | +def question_from(qdict: QDict) -> Question: | ||
| 776 | + ''' | ||
| 777 | + Converts a question specified in a dict into an instance of Question() | ||
| 778 | + ''' | ||
| 779 | + types = { | ||
| 780 | + 'radio': QuestionRadio, | ||
| 781 | + 'checkbox': QuestionCheckbox, | ||
| 782 | + 'text': QuestionText, | ||
| 783 | + 'text-regex': QuestionTextRegex, | ||
| 784 | + 'numeric-interval': QuestionNumericInterval, | ||
| 785 | + 'textarea': QuestionTextArea, | ||
| 786 | + # 'code': QuestionCode, | ||
| 787 | + # -- informative panels -- | ||
| 788 | + 'information': QuestionInformation, | ||
| 789 | + 'success': QuestionInformation, | ||
| 790 | + 'warning': QuestionInformation, | ||
| 791 | + 'alert': QuestionInformation, | ||
| 792 | + } | ||
| 793 | + | ||
| 794 | + # Get class for this question type | ||
| 795 | + try: | ||
| 796 | + qclass = types[qdict['type']] | ||
| 797 | + except KeyError: | ||
| 798 | + logger.error('Invalid type "%s" in "%s"', | ||
| 799 | + qdict['type'], qdict['ref']) | ||
| 800 | + raise | ||
| 801 | + | ||
| 802 | + # Create an instance of Question() of appropriate type | ||
| 803 | + try: | ||
| 804 | + qinstance = qclass(QDict(qdict)) | ||
| 805 | + except QuestionException: | ||
| 806 | + logger.error('Error generating "%s" in %s/%s', | ||
| 807 | + qdict['ref'], qdict['path'], qdict['filename']) | ||
| 808 | + raise | ||
| 809 | + | ||
| 810 | + return qinstance | ||
| 811 | + | ||
| 812 | + | ||
| 606 | # ============================================================================ | 813 | # ============================================================================ |
| 607 | class QFactory(): | 814 | class QFactory(): |
| 608 | ''' | 815 | ''' |
| @@ -636,24 +843,8 @@ class QFactory(): | @@ -636,24 +843,8 @@ class QFactory(): | ||
| 636 | grade = question['grade'] # get grade | 843 | grade = question['grade'] # get grade |
| 637 | ''' | 844 | ''' |
| 638 | 845 | ||
| 639 | - # Depending on the type of question, a different question class will be | ||
| 640 | - # instantiated. All these classes derive from the base class `Question`. | ||
| 641 | - _types = { | ||
| 642 | - 'radio': QuestionRadio, | ||
| 643 | - 'checkbox': QuestionCheckbox, | ||
| 644 | - 'text': QuestionText, | ||
| 645 | - 'text-regex': QuestionTextRegex, | ||
| 646 | - 'numeric-interval': QuestionNumericInterval, | ||
| 647 | - 'textarea': QuestionTextArea, | ||
| 648 | - # -- informative panels -- | ||
| 649 | - 'information': QuestionInformation, | ||
| 650 | - 'success': QuestionInformation, | ||
| 651 | - 'warning': QuestionInformation, | ||
| 652 | - 'alert': QuestionInformation, | ||
| 653 | - } | ||
| 654 | - | ||
| 655 | def __init__(self, qdict: QDict = QDict({})) -> None: | 846 | def __init__(self, qdict: QDict = QDict({})) -> None: |
| 656 | - self.question = qdict | 847 | + self.qdict = qdict |
| 657 | 848 | ||
| 658 | # ------------------------------------------------------------------------ | 849 | # ------------------------------------------------------------------------ |
| 659 | async def gen_async(self) -> Question: | 850 | async def gen_async(self) -> Question: |
| @@ -662,44 +853,28 @@ class QFactory(): | @@ -662,44 +853,28 @@ class QFactory(): | ||
| 662 | which is a descendent of base class Question. | 853 | which is a descendent of base class Question. |
| 663 | ''' | 854 | ''' |
| 664 | 855 | ||
| 665 | - logger.debug('generating %s...', self.question["ref"]) | 856 | + logger.debug('generating %s...', self.qdict["ref"]) |
| 666 | # Shallow copy so that script generated questions will not replace | 857 | # Shallow copy so that script generated questions will not replace |
| 667 | # the original generators | 858 | # the original generators |
| 668 | - question = self.question.copy() | ||
| 669 | - question['qid'] = str(uuid.uuid4()) # unique for each question | 859 | + qdict = self.qdict.copy() |
| 860 | + qdict['qid'] = str(uuid.uuid4()) # unique for each question | ||
| 670 | 861 | ||
| 671 | # If question is of generator type, an external program will be run | 862 | # If question is of generator type, an external program will be run |
| 672 | # which will print a valid question in yaml format to stdout. This | 863 | # which will print a valid question in yaml format to stdout. This |
| 673 | # output is then yaml parsed into a dictionary `q`. | 864 | # output is then yaml parsed into a dictionary `q`. |
| 674 | - if question['type'] == 'generator': | ||
| 675 | - logger.debug(' \\_ Running "%s".', question['script']) | ||
| 676 | - question.setdefault('args', []) | ||
| 677 | - question.setdefault('stdin', '') | ||
| 678 | - script = path.join(question['path'], question['script']) | 865 | + if qdict['type'] == 'generator': |
| 866 | + logger.debug(' \\_ Running "%s".', qdict['script']) | ||
| 867 | + qdict.setdefault('args', []) | ||
| 868 | + qdict.setdefault('stdin', '') | ||
| 869 | + script = path.join(qdict['path'], qdict['script']) | ||
| 679 | out = await run_script_async(script=script, | 870 | out = await run_script_async(script=script, |
| 680 | - args=question['args'], | ||
| 681 | - stdin=question['stdin']) | ||
| 682 | - question.update(out) | 871 | + args=qdict['args'], |
| 872 | + stdin=qdict['stdin']) | ||
| 873 | + qdict.update(out) | ||
| 683 | 874 | ||
| 684 | - # Get class for this question type | ||
| 685 | - try: | ||
| 686 | - qclass = self._types[question['type']] | ||
| 687 | - except KeyError: | ||
| 688 | - logger.error('Invalid type "%s" in "%s"', | ||
| 689 | - question['type'], question['ref']) | ||
| 690 | - raise | ||
| 691 | - | ||
| 692 | - # Finally create an instance of Question() | ||
| 693 | - try: | ||
| 694 | - qinstance = qclass(QDict(question)) | ||
| 695 | - except QuestionException: | ||
| 696 | - logger.error('Error generating question "%s". See "%s/%s"', | ||
| 697 | - question['ref'], | ||
| 698 | - question['path'], | ||
| 699 | - question['filename']) | ||
| 700 | - raise | ||
| 701 | - | ||
| 702 | - return qinstance | 875 | + question = question_from(qdict) # returns a Question instance |
| 876 | + question.gen() | ||
| 877 | + return question | ||
| 703 | 878 | ||
| 704 | # ------------------------------------------------------------------------ | 879 | # ------------------------------------------------------------------------ |
| 705 | def generate(self) -> Question: | 880 | def generate(self) -> Question: |
perguntations/serve.py
| @@ -5,8 +5,8 @@ Handles the web, http & html part of the application interface. | @@ -5,8 +5,8 @@ Handles the web, http & html part of the application interface. | ||
| 5 | Uses the tornadoweb framework. | 5 | Uses the tornadoweb framework. |
| 6 | ''' | 6 | ''' |
| 7 | 7 | ||
| 8 | - | ||
| 9 | # python standard library | 8 | # python standard library |
| 9 | +import asyncio | ||
| 10 | import base64 | 10 | import base64 |
| 11 | import functools | 11 | import functools |
| 12 | import json | 12 | import json |
| @@ -161,6 +161,54 @@ class BaseHandler(tornado.web.RequestHandler): | @@ -161,6 +161,54 @@ class BaseHandler(tornado.web.RequestHandler): | ||
| 161 | # AdminSocketHandler.send_updates(chat) # send to clients | 161 | # AdminSocketHandler.send_updates(chat) # send to clients |
| 162 | 162 | ||
| 163 | # ---------------------------------------------------------------------------- | 163 | # ---------------------------------------------------------------------------- |
| 164 | +# pylint: disable=abstract-method | ||
| 165 | +class LoginHandler(BaseHandler): | ||
| 166 | + '''Handles /login''' | ||
| 167 | + | ||
| 168 | + _prefix = re.compile(r'[a-z]') | ||
| 169 | + _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' | ||
| 173 | + } | ||
| 174 | + | ||
| 175 | + def get(self): | ||
| 176 | + '''Render login page.''' | ||
| 177 | + self.render('login.html', error='') | ||
| 178 | + | ||
| 179 | + async def post(self): | ||
| 180 | + '''Authenticates student and login.''' | ||
| 181 | + uid = self._prefix.sub('', self.get_body_argument('uid')) | ||
| 182 | + password = self.get_body_argument('pw') | ||
| 183 | + headers = { | ||
| 184 | + 'remote_ip': self.request.remote_ip, | ||
| 185 | + 'user_agent': self.request.headers.get('User-Agent') | ||
| 186 | + } | ||
| 187 | + | ||
| 188 | + error = await self.testapp.login(uid, password, headers) | ||
| 189 | + | ||
| 190 | + if error: | ||
| 191 | + await asyncio.sleep(3) # delay to avoid spamming the server... | ||
| 192 | + self.render('login.html', error=self._error_msg[error]) | ||
| 193 | + else: | ||
| 194 | + self.set_secure_cookie('perguntations_user', str(uid)) | ||
| 195 | + self.redirect('/') | ||
| 196 | + | ||
| 197 | + | ||
| 198 | +# ---------------------------------------------------------------------------- | ||
| 199 | +# pylint: disable=abstract-method | ||
| 200 | +class LogoutHandler(BaseHandler): | ||
| 201 | + '''Handle /logout''' | ||
| 202 | + | ||
| 203 | + @tornado.web.authenticated | ||
| 204 | + def get(self): | ||
| 205 | + '''Logs out a user.''' | ||
| 206 | + self.clear_cookie('perguntations_user') | ||
| 207 | + self.testapp.logout(self.current_user) | ||
| 208 | + self.render('login.html', error='') | ||
| 209 | + | ||
| 210 | + | ||
| 211 | +# ---------------------------------------------------------------------------- | ||
| 164 | # Test shown to students | 212 | # Test shown to students |
| 165 | # ---------------------------------------------------------------------------- | 213 | # ---------------------------------------------------------------------------- |
| 166 | # pylint: disable=abstract-method | 214 | # pylint: disable=abstract-method |
| @@ -179,6 +227,7 @@ class RootHandler(BaseHandler): | @@ -179,6 +227,7 @@ class RootHandler(BaseHandler): | ||
| 179 | 'text-regex': 'question-text.html', | 227 | 'text-regex': 'question-text.html', |
| 180 | 'numeric-interval': 'question-text.html', | 228 | 'numeric-interval': 'question-text.html', |
| 181 | 'textarea': 'question-textarea.html', | 229 | 'textarea': 'question-textarea.html', |
| 230 | + 'code': 'question-textarea.html', | ||
| 182 | # -- information panels -- | 231 | # -- information panels -- |
| 183 | 'information': 'question-information.html', | 232 | 'information': 'question-information.html', |
| 184 | 'success': 'question-information.html', | 233 | 'success': 'question-information.html', |
| @@ -190,18 +239,19 @@ class RootHandler(BaseHandler): | @@ -190,18 +239,19 @@ class RootHandler(BaseHandler): | ||
| 190 | @tornado.web.authenticated | 239 | @tornado.web.authenticated |
| 191 | async def get(self): | 240 | async def get(self): |
| 192 | ''' | 241 | ''' |
| 193 | - Sends test to student or redirects 0 to admin page | 242 | + Handles GET / |
| 243 | + Sends test to student or redirects 0 to admin page. | ||
| 244 | + Multiple calls to this function will return the same test. | ||
| 194 | ''' | 245 | ''' |
| 195 | 246 | ||
| 196 | uid = self.current_user | 247 | uid = self.current_user |
| 197 | - logging.info('"%s" GET /', uid) | 248 | + logging.debug('"%s" GET /', uid) |
| 249 | + | ||
| 198 | if uid == '0': | 250 | if uid == '0': |
| 199 | self.redirect('/admin') | 251 | self.redirect('/admin') |
| 252 | + return | ||
| 200 | 253 | ||
| 201 | - test = self.testapp.get_student_test(uid) # reloading returns same test | ||
| 202 | - if test is None: | ||
| 203 | - test = await self.testapp.generate_test(uid) | ||
| 204 | - | 254 | + test = await self.testapp.get_test_or_generate(uid) |
| 205 | self.render('test.html', t=test, md=md_to_html, templ=self._templates) | 255 | self.render('test.html', t=test, md=md_to_html, templ=self._templates) |
| 206 | 256 | ||
| 207 | 257 | ||
| @@ -222,7 +272,7 @@ class RootHandler(BaseHandler): | @@ -222,7 +272,7 @@ class RootHandler(BaseHandler): | ||
| 222 | logging.debug('"%s" POST /', uid) | 272 | logging.debug('"%s" POST /', uid) |
| 223 | 273 | ||
| 224 | try: | 274 | try: |
| 225 | - test = self.testapp.get_student_test(uid) | 275 | + test = self.testapp.get_test(uid) |
| 226 | except KeyError as exc: | 276 | except KeyError as exc: |
| 227 | logging.warning('"%s" POST / raised 403 Forbidden', uid) | 277 | logging.warning('"%s" POST / raised 403 Forbidden', uid) |
| 228 | raise tornado.web.HTTPError(403) from exc # Forbidden | 278 | raise tornado.web.HTTPError(403) from exc # Forbidden |
| @@ -232,7 +282,6 @@ class RootHandler(BaseHandler): | @@ -232,7 +282,6 @@ class RootHandler(BaseHandler): | ||
| 232 | qid = str(i) | 282 | qid = str(i) |
| 233 | if 'answered-' + qid in self.request.arguments: | 283 | if 'answered-' + qid in self.request.arguments: |
| 234 | ans[i] = self.get_body_arguments(qid) | 284 | ans[i] = self.get_body_arguments(qid) |
| 235 | - # print(i, ans[i]) | ||
| 236 | 285 | ||
| 237 | # remove enclosing list in some question types | 286 | # remove enclosing list in some question types |
| 238 | if question['type'] == 'radio': | 287 | if question['type'] == 'radio': |
| @@ -241,62 +290,22 @@ class RootHandler(BaseHandler): | @@ -241,62 +290,22 @@ class RootHandler(BaseHandler): | ||
| 241 | else: | 290 | else: |
| 242 | ans[i] = ans[i][0] | 291 | ans[i] = ans[i][0] |
| 243 | elif question['type'] in ('text', 'text-regex', 'textarea', | 292 | elif question['type'] in ('text', 'text-regex', 'textarea', |
| 244 | - 'numeric-interval'): | 293 | + 'numeric-interval', 'code'): |
| 245 | ans[i] = ans[i][0] | 294 | ans[i] = ans[i][0] |
| 246 | 295 | ||
| 247 | - # correct answered questions and logout | ||
| 248 | - await self.testapp.correct_test(uid, ans) | 296 | + # submit answered questions, correct |
| 297 | + await self.testapp.submit_test(uid, ans) | ||
| 249 | 298 | ||
| 250 | # show final grade and grades of other tests in the database | 299 | # show final grade and grades of other tests in the database |
| 251 | - allgrades = self.testapp.get_student_grades_from_all_tests(uid) | 300 | + # allgrades = self.testapp.get_student_grades_from_all_tests(uid) |
| 301 | + grade = self.testapp.get_student_grade(uid) | ||
| 252 | 302 | ||
| 303 | + self.render('grade.html', t=test) | ||
| 253 | self.clear_cookie('perguntations_user') | 304 | self.clear_cookie('perguntations_user') |
| 254 | - self.render('grade.html', t=test, allgrades=allgrades) | ||
| 255 | self.testapp.logout(uid) | 305 | self.testapp.logout(uid) |
| 256 | 306 | ||
| 257 | timeit_finish = timer() | 307 | timeit_finish = timer() |
| 258 | - logging.info(' correction took %fs', timeit_finish-timeit_start) | ||
| 259 | - | ||
| 260 | -# ---------------------------------------------------------------------------- | ||
| 261 | -# pylint: disable=abstract-method | ||
| 262 | -class LoginHandler(BaseHandler): | ||
| 263 | - '''Handles /login''' | ||
| 264 | - | ||
| 265 | - _prefix = re.compile(r'[a-z]') | ||
| 266 | - | ||
| 267 | - def get(self): | ||
| 268 | - '''Render login page.''' | ||
| 269 | - self.render('login.html', error='') | ||
| 270 | - | ||
| 271 | - async def post(self): | ||
| 272 | - '''Authenticates student and login.''' | ||
| 273 | - uid = self._prefix.sub('', self.get_body_argument('uid')) | ||
| 274 | - password = self.get_body_argument('pw') | ||
| 275 | - login_ok = await self.testapp.login(uid, password) | ||
| 276 | - | ||
| 277 | - if login_ok: | ||
| 278 | - self.set_secure_cookie('perguntations_user', str(uid), expires_days=1) | ||
| 279 | - self.redirect('/') | ||
| 280 | - else: | ||
| 281 | - self.render('login.html', error='Não autorizado ou senha inválida') | ||
| 282 | - | ||
| 283 | - | ||
| 284 | -# ---------------------------------------------------------------------------- | ||
| 285 | -# pylint: disable=abstract-method | ||
| 286 | -class LogoutHandler(BaseHandler): | ||
| 287 | - '''Handle /logout''' | ||
| 288 | - | ||
| 289 | - @tornado.web.authenticated | ||
| 290 | - def get(self): | ||
| 291 | - '''Logs out a user.''' | ||
| 292 | - self.clear_cookie('perguntations_user') | ||
| 293 | - self.testapp.logout(self.current_user) | ||
| 294 | - self.redirect('/') | ||
| 295 | - | ||
| 296 | - def on_finish(self): | ||
| 297 | - self.testapp.logout(self.current_user) | ||
| 298 | - | ||
| 299 | - | 308 | + logging.info(' elapsed time: %fs', timeit_finish-timeit_start) |
| 300 | 309 | ||
| 301 | 310 | ||
| 302 | # ---------------------------------------------------------------------------- | 311 | # ---------------------------------------------------------------------------- |
| @@ -456,7 +465,6 @@ class FileHandler(BaseHandler): | @@ -456,7 +465,6 @@ class FileHandler(BaseHandler): | ||
| 456 | break | 465 | break |
| 457 | 466 | ||
| 458 | 467 | ||
| 459 | - | ||
| 460 | # --- REVIEW ----------------------------------------------------------------- | 468 | # --- REVIEW ----------------------------------------------------------------- |
| 461 | # pylint: disable=abstract-method | 469 | # pylint: disable=abstract-method |
| 462 | class ReviewHandler(BaseHandler): | 470 | class ReviewHandler(BaseHandler): |
| @@ -471,6 +479,7 @@ class ReviewHandler(BaseHandler): | @@ -471,6 +479,7 @@ class ReviewHandler(BaseHandler): | ||
| 471 | 'text-regex': 'review-question-text.html', | 479 | 'text-regex': 'review-question-text.html', |
| 472 | 'numeric-interval': 'review-question-text.html', | 480 | 'numeric-interval': 'review-question-text.html', |
| 473 | 'textarea': 'review-question-text.html', | 481 | 'textarea': 'review-question-text.html', |
| 482 | + 'code': 'review-question-text.html', | ||
| 474 | # -- information panels -- | 483 | # -- information panels -- |
| 475 | 'information': 'review-question-information.html', | 484 | 'information': 'review-question-information.html', |
| 476 | 'success': 'review-question-information.html', | 485 | 'success': 'review-question-information.html', |
perguntations/templates/grade.html
| @@ -40,68 +40,22 @@ | @@ -40,68 +40,22 @@ | ||
| 40 | <!-- ================================================================== --> | 40 | <!-- ================================================================== --> |
| 41 | <div class="container"> | 41 | <div class="container"> |
| 42 | <div class="jumbotron"> | 42 | <div class="jumbotron"> |
| 43 | - {% if t['state'] == 'FINISHED' %} | ||
| 44 | - <h1>Resultado: | ||
| 45 | - <strong>{{ f'{round(t["grade"], 3)}' }}</strong> | ||
| 46 | - valores na escala de {{t['scale'][0]}} a {{t['scale'][1]}}. | ||
| 47 | - </h1> | ||
| 48 | - <p>O seu teste foi correctamente entregue e a nota registada.</p> | ||
| 49 | - <p><a href="/logout" class="btn btn-primary btn-lg active" role="button">Clique aqui para sair do teste</a></p> | 43 | + {% if t['state'] == 'CORRECTED' %} |
| 50 | {% if t['grade'] - t['scale'][0] >= 0.75*(t['scale'][1] - t['scale'][0]) %} | 44 | {% if t['grade'] - t['scale'][0] >= 0.75*(t['scale'][1] - t['scale'][0]) %} |
| 51 | <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i> | 45 | <i class="fas fa-thumbs-up fa-5x text-success" aria-hidden="true"></i> |
| 52 | {% end %} | 46 | {% end %} |
| 47 | + <h3>Resultado: | ||
| 48 | + <strong>{{ f'{round(t["grade"], 3)}' }}</strong> | ||
| 49 | + valores na escala [{{t['scale'][0]}},{{t['scale'][1]}}]. | ||
| 50 | + </h3> | ||
| 51 | + {% elif t['state'] == 'SUBMITTED' %} | ||
| 52 | + <h3>A prova foi submetida com sucesso. Vai ser corrigida mais tarde.</h3> | ||
| 53 | {% elif t['state'] == 'QUIT' %} | 53 | {% elif t['state'] == 'QUIT' %} |
| 54 | - <p>Foi registada a sua desistência da prova.</p> | 54 | + <h3>Foi registada a sua desistência da prova.</h3> |
| 55 | {% end %} | 55 | {% end %} |
| 56 | 56 | ||
| 57 | + <p><a href="/logout" class="btn btn-primary btn-lg active" role="button">Clique aqui para terminar</a></p> | ||
| 57 | </div> <!-- jumbotron --> | 58 | </div> <!-- jumbotron --> |
| 58 | - | ||
| 59 | - <div class="card"> | ||
| 60 | - <h5 class="card-header"> | ||
| 61 | - Histórico de resultados | ||
| 62 | - </h5> | ||
| 63 | - <table class="table table-condensed noleftmargin"> | ||
| 64 | - <thead> | ||
| 65 | - <tr> | ||
| 66 | - <th>Prova</th> | ||
| 67 | - <th>Data</th> | ||
| 68 | - <th>Hora</th> | ||
| 69 | - <th>Nota</th> | ||
| 70 | - </tr> | ||
| 71 | - </thead> | ||
| 72 | - <tbody> | ||
| 73 | - {% for g in allgrades %} | ||
| 74 | - <tr> | ||
| 75 | - <td>{{g[0]}}</td> <!-- teste --> | ||
| 76 | - <td>{{g[2][:10]}}</td> <!-- data --> | ||
| 77 | - <td>{{g[2][11:19]}}</td> <!-- hora --> | ||
| 78 | - <td> <!-- progress column --> | ||
| 79 | - <div class="progress" style="height: 20px;"> | ||
| 80 | - <div class="progress-bar | ||
| 81 | - {% if g[1] - t['scale'][0] < 0.5*(t['scale'][1] - t['scale'][0]) %} | ||
| 82 | - bg-danger | ||
| 83 | - {% elif g[1] - t['scale'][0] < 0.75*(t['scale'][1] - t['scale'][0]) %} | ||
| 84 | - bg-warning | ||
| 85 | - {% else %} | ||
| 86 | - bg-success | ||
| 87 | - {% end %} | ||
| 88 | - " | ||
| 89 | - role="progressbar" | ||
| 90 | - aria-valuenow="{{ 100*(g[1] - t['scale'][0])/(t['scale'][1] - t['scale'][0]) }}" | ||
| 91 | - aria-valuemin="0" | ||
| 92 | - aria-valuemax="100" | ||
| 93 | - style="min-width: 2em; width: {{ 100*(g[1]-t['scale'][0])/(t['scale'][1]-t['scale'][0]) }}%;"> | ||
| 94 | - | ||
| 95 | - {{ str(round(g[1], 1)) }} | ||
| 96 | - | ||
| 97 | - </div> <!-- progress-bar --> | ||
| 98 | - </div> <!-- progress --> | ||
| 99 | - </td> <!-- progress column --> | ||
| 100 | - </tr> | ||
| 101 | - {% end %} | ||
| 102 | - </tbody> | ||
| 103 | - </table> | ||
| 104 | - </div> <!-- panel --> | ||
| 105 | </div> <!-- container --> | 59 | </div> <!-- container --> |
| 106 | </body> | 60 | </body> |
| 107 | </html> | 61 | </html> |
perguntations/templates/login.html
| @@ -12,7 +12,6 @@ | @@ -12,7 +12,6 @@ | ||
| 12 | 12 | ||
| 13 | <!-- Scripts --> | 13 | <!-- Scripts --> |
| 14 | <script src="/static/jquery/jquery.min.js"></script> | 14 | <script src="/static/jquery/jquery.min.js"></script> |
| 15 | - <!-- <script defer src="/static/popper.js/popper.min.js"></script> --> | ||
| 16 | <script defer src="/static/fontawesome-free/js/all.min.js"></script> | 15 | <script defer src="/static/fontawesome-free/js/all.min.js"></script> |
| 17 | <script defer src="/static/bootstrap/js/bootstrap.bundle.min.js"></script> | 16 | <script defer src="/static/bootstrap/js/bootstrap.bundle.min.js"></script> |
| 18 | 17 |
perguntations/templates/review-question.html
| @@ -32,45 +32,46 @@ | @@ -32,45 +32,46 @@ | ||
| 32 | </p> | 32 | </p> |
| 33 | </div> <!-- card-body --> | 33 | </div> <!-- card-body --> |
| 34 | 34 | ||
| 35 | - <div class="card-footer"> | ||
| 36 | - {% if q['grade'] > 0.99 %} | ||
| 37 | - <p class="text-success"> | ||
| 38 | - <i class="far fa-thumbs-up fa-3x" aria-hidden="true"></i> | ||
| 39 | - {{ round(q['grade'] * q['points'], 2) }} | ||
| 40 | - pontos | ||
| 41 | - </p> | ||
| 42 | - <p class="text-success">{{ q['comments'] }}</p> | ||
| 43 | - {% elif q['grade'] > 0.49 %} | ||
| 44 | - <p class="text-warning"> | ||
| 45 | - <i class="fas fa-exclamation-triangle fa-3x" aria-hidden="true"></i> | ||
| 46 | - {{ round(q['grade'] * q['points'], 2) }} | ||
| 47 | - pontos | ||
| 48 | - </p> | ||
| 49 | - <p class="text-warning">{{ q['comments'] }}</p> | ||
| 50 | - {% if q.get('solution', '') %} | ||
| 51 | - <hr> | ||
| 52 | - {{ md('**Solução:** \n\n' + q['solution']) }} | 35 | + {% if 'grade' in q %} |
| 36 | + <div class="card-footer"> | ||
| 37 | + {% if q['grade'] > 0.999 %} | ||
| 38 | + <p class="text-success"> | ||
| 39 | + <i class="far fa-thumbs-up fa-3x" aria-hidden="true"></i> | ||
| 40 | + {{ round(q['grade'] * q['points'], 2) }} | ||
| 41 | + pontos | ||
| 42 | + </p> | ||
| 43 | + <p class="text-success">{{ md(q['comments']) }}</p> | ||
| 44 | + {% elif q['grade'] >= 0.5 %} | ||
| 45 | + <p class="text-warning"> | ||
| 46 | + <i class="fas fa-exclamation-triangle fa-3x" aria-hidden="true"></i> | ||
| 47 | + {{ round(q['grade'] * q['points'], 2) }} | ||
| 48 | + pontos | ||
| 49 | + </p> | ||
| 50 | + <p class="text-warning">{{ md(q['comments']) }}</p> | ||
| 51 | + {% if q['solution'] %} | ||
| 52 | + <hr> | ||
| 53 | + {{ md('**Solução:** \n\n' + q['solution']) }} | ||
| 54 | + {% end %} | ||
| 55 | + {% else %} | ||
| 56 | + <p class="text-danger"> | ||
| 57 | + <i class="far fa-thumbs-down fa-3x" aria-hidden="true"></i> | ||
| 58 | + {{ round(q['grade'] * q['points'], 2) }} | ||
| 59 | + pontos | ||
| 60 | + </p> | ||
| 61 | + <p class="text-danger">{{ md(q['comments']) }}</p> | ||
| 62 | + {% if q['solution'] %} | ||
| 63 | + <hr> | ||
| 64 | + {{ md('**Solução:** \n\n' + q['solution']) }} | ||
| 65 | + {% end %} | ||
| 53 | {% end %} | 66 | {% end %} |
| 54 | - {% else %} | ||
| 55 | - <p class="text-danger"> | ||
| 56 | - <i class="far fa-thumbs-down fa-3x" aria-hidden="true"></i> | ||
| 57 | - {{ round(q['grade'] * q['points'], 2) }} | ||
| 58 | - pontos | ||
| 59 | - </p> | ||
| 60 | - <p class="text-danger">{{ q['comments'] }}</p> | ||
| 61 | - {% if q.get('solution', '') %} | 67 | + |
| 68 | + {% if t['show_ref'] %} | ||
| 62 | <hr> | 69 | <hr> |
| 63 | - {{ md('**Solução:** \n\n' + q['solution']) }} | 70 | + file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> |
| 71 | + ref: <code>{{ q['ref'] }}</code> | ||
| 64 | {% end %} | 72 | {% end %} |
| 65 | - {% end %} | ||
| 66 | - | ||
| 67 | - {% if t['show_ref'] %} | ||
| 68 | - <hr> | ||
| 69 | - file: <code>{{ q['path'] }}/{{ q['filename'] }}</code><br> | ||
| 70 | - ref: <code>{{ q['ref'] }}</code> | ||
| 71 | - {% end %} | ||
| 72 | - | ||
| 73 | - </div> <!-- card-footer --> | 73 | + </div> <!-- card-footer --> |
| 74 | + {% end %} | ||
| 74 | </div> <!-- card --> | 75 | </div> <!-- card --> |
| 75 | 76 | ||
| 76 | {% else %} | 77 | {% else %} |
| @@ -97,12 +98,12 @@ | @@ -97,12 +98,12 @@ | ||
| 97 | </small> | 98 | </small> |
| 98 | </p> | 99 | </p> |
| 99 | </div> <!-- card-body --> | 100 | </div> <!-- card-body --> |
| 101 | + | ||
| 100 | <div class="card-footer"> | 102 | <div class="card-footer"> |
| 101 | <p class="text-secondary"> | 103 | <p class="text-secondary"> |
| 102 | <i class="fas fa-ban fa-3x" aria-hidden="true"></i> | 104 | <i class="fas fa-ban fa-3x" aria-hidden="true"></i> |
| 103 | - {{ round(q['grade'] * q['points'], 2) }} pontos<br> | ||
| 104 | - {{ q['comments'] }} | ||
| 105 | - {% if q.get('solution', '') %} | 105 | + {{ md(q['comments']) }} |
| 106 | + {% if q['solution'] %} | ||
| 106 | <hr> | 107 | <hr> |
| 107 | {{ md('**Solução:** \n\n' + q['solution']) }} | 108 | {{ md('**Solução:** \n\n' + q['solution']) }} |
| 108 | {% end %} | 109 | {% end %} |
perguntations/templates/review.html
| @@ -97,7 +97,7 @@ | @@ -97,7 +97,7 @@ | ||
| 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'], 1) }}</span> valores | 100 | + <span class="badge badge-primary">{{ round(t['grade'], 2) }}</span> valores |
| 101 | {% if t['state'] == 'QUIT' %} | 101 | {% if t['state'] == 'QUIT' %} |
| 102 | (DESISTÊNCIA) | 102 | (DESISTÊNCIA) |
| 103 | {% end %} | 103 | {% end %} |
perguntations/templates/test.html
| @@ -44,7 +44,7 @@ | @@ -44,7 +44,7 @@ | ||
| 44 | <!-- ===================================================================== --> | 44 | <!-- ===================================================================== --> |
| 45 | <body> | 45 | <body> |
| 46 | <!-- ===================================================================== --> | 46 | <!-- ===================================================================== --> |
| 47 | -<div class="progress fixed-top" style="height: 61px; border-radius: 0px;"> | 47 | +<div class="progress fixed-top" style="height: 62px; border-radius: 0px;"> |
| 48 | <div class="progress-bar bg-secondary" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div> | 48 | <div class="progress-bar bg-secondary" role="progressbar" style="width: 100%" aria-valuenow="100" aria-valuemin="0" aria-valuemax="100"></div> |
| 49 | </div> | 49 | </div> |
| 50 | 50 | ||
| @@ -94,10 +94,6 @@ | @@ -94,10 +94,6 @@ | ||
| 94 | 94 | ||
| 95 | <h5> | 95 | <h5> |
| 96 | <div class="row"> | 96 | <div class="row"> |
| 97 | - <label for="inicio" class="col-sm-3">Início:</label> | ||
| 98 | - <div class="col-sm-9" id="inicio">{{ str(t['start_time'].time())[:8]}}</div> | ||
| 99 | - </div> | ||
| 100 | - <div class="row"> | ||
| 101 | <label for="duracao" class="col-sm-3">Duração:</label> | 97 | <label for="duracao" class="col-sm-3">Duração:</label> |
| 102 | <div class="col-sm-9" id="duracao">{{ str(t['duration'])+' minutos' if t['duration'] > 0 else 'sem limite de tempo' }}</div> | 98 | <div class="col-sm-9" id="duracao">{{ str(t['duration'])+' minutos' if t['duration'] > 0 else 'sem limite de tempo' }}</div> |
| 103 | </div> | 99 | </div> |
perguntations/test.py
| 1 | ''' | 1 | ''' |
| 2 | -TestFactory - generates tests for students | ||
| 3 | Test - instances of this class are individual tests | 2 | Test - instances of this class are individual tests |
| 4 | ''' | 3 | ''' |
| 5 | 4 | ||
| 6 | - | ||
| 7 | # python standard library | 5 | # python standard library |
| 8 | -from os import path | ||
| 9 | -import random | ||
| 10 | from datetime import datetime | 6 | from datetime import datetime |
| 7 | +import json | ||
| 11 | import logging | 8 | import logging |
| 12 | -import re | ||
| 13 | - | ||
| 14 | -# this project | ||
| 15 | -from perguntations.questions import QFactory, QuestionException | ||
| 16 | -from perguntations.tools import load_yaml | 9 | +from math import nan |
| 10 | +from os import path | ||
| 17 | 11 | ||
| 18 | # Logger configuration | 12 | # Logger configuration |
| 19 | logger = logging.getLogger(__name__) | 13 | logger = logging.getLogger(__name__) |
| 20 | 14 | ||
| 21 | 15 | ||
| 22 | # ============================================================================ | 16 | # ============================================================================ |
| 23 | -class TestFactoryException(Exception): | ||
| 24 | - '''exception raised in this module''' | ||
| 25 | - | ||
| 26 | - | ||
| 27 | -# ============================================================================ | ||
| 28 | -class TestFactory(dict): | ||
| 29 | - ''' | ||
| 30 | - Each instance of TestFactory() is a test generator. | ||
| 31 | - For example, if we want to serve two different tests, then we need two | ||
| 32 | - instances of TestFactory(), one for each test. | ||
| 33 | - ''' | ||
| 34 | - | ||
| 35 | - # ------------------------------------------------------------------------ | ||
| 36 | - def __init__(self, conf): | ||
| 37 | - ''' | ||
| 38 | - Loads configuration from yaml file, then overrides some configurations | ||
| 39 | - using the conf argument. | ||
| 40 | - Base questions are added to a pool of questions factories. | ||
| 41 | - ''' | ||
| 42 | - | ||
| 43 | - # --- set test defaults and then use given configuration | ||
| 44 | - super().__init__({ # defaults | ||
| 45 | - 'title': '', | ||
| 46 | - 'show_points': True, | ||
| 47 | - 'scale': None, # or [0, 20] | ||
| 48 | - 'duration': 0, # 0=infinite | ||
| 49 | - 'autosubmit': False, | ||
| 50 | - 'debug': False, | ||
| 51 | - 'show_ref': False, | ||
| 52 | - }) | ||
| 53 | - self.update(conf) | ||
| 54 | - | ||
| 55 | - # --- perform sanity checks and normalize the test questions | ||
| 56 | - self.sanity_checks() | ||
| 57 | - logger.info('Sanity checks PASSED.') | ||
| 58 | - | ||
| 59 | - # --- find refs of all questions used in the test | ||
| 60 | - qrefs = {r for qq in self['questions'] for r in qq['ref']} | ||
| 61 | - logger.info('Declared %d questions (each test uses %d).', | ||
| 62 | - len(qrefs), len(self["questions"])) | ||
| 63 | - | ||
| 64 | - # --- for review, we are done. no factories needed | ||
| 65 | - if self['review']: | ||
| 66 | - logger.info('Review mode. No questions loaded. No factories.') | ||
| 67 | - return | ||
| 68 | - | ||
| 69 | - # --- load and build question factories | ||
| 70 | - self.question_factory = {} | ||
| 71 | - | ||
| 72 | - counter = 1 | ||
| 73 | - for file in self["files"]: | ||
| 74 | - fullpath = path.normpath(path.join(self["questions_dir"], file)) | ||
| 75 | - (dirname, filename) = path.split(fullpath) | ||
| 76 | - | ||
| 77 | - logger.info('Loading "%s"...', fullpath) | ||
| 78 | - questions = load_yaml(fullpath) # , default=[]) | ||
| 79 | - | ||
| 80 | - for i, question in enumerate(questions): | ||
| 81 | - # make sure every question in the file is a dictionary | ||
| 82 | - if not isinstance(question, dict): | ||
| 83 | - msg = f'Question {i} in {file} is not a dictionary' | ||
| 84 | - raise TestFactoryException(msg) | ||
| 85 | - | ||
| 86 | - # check if ref is missing, then set to '/path/file.yaml:3' | ||
| 87 | - if 'ref' not in question: | ||
| 88 | - question['ref'] = f'{file}:{i:04}' | ||
| 89 | - logger.warning('Missing ref set to "%s"', question["ref"]) | ||
| 90 | - | ||
| 91 | - # check for duplicate refs | ||
| 92 | - if question['ref'] in self.question_factory: | ||
| 93 | - other = self.question_factory[question['ref']] | ||
| 94 | - otherfile = path.join(other.question['path'], | ||
| 95 | - other.question['filename']) | ||
| 96 | - msg = (f'Duplicate reference "{question["ref"]}" in files ' | ||
| 97 | - f'"{otherfile}" and "{fullpath}".') | ||
| 98 | - raise TestFactoryException(msg) | ||
| 99 | - | ||
| 100 | - # make factory only for the questions used in the test | ||
| 101 | - if question['ref'] in qrefs: | ||
| 102 | - question.setdefault('type', 'information') | ||
| 103 | - question.update({ | ||
| 104 | - 'filename': filename, | ||
| 105 | - 'path': dirname, | ||
| 106 | - 'index': i # position in the file, 0 based | ||
| 107 | - }) | ||
| 108 | - | ||
| 109 | - self.question_factory[question['ref']] = QFactory(question) | ||
| 110 | - | ||
| 111 | - # check if all the questions can be correctly generated | ||
| 112 | - try: | ||
| 113 | - self.question_factory[question['ref']].generate() | ||
| 114 | - except Exception as exc: | ||
| 115 | - msg = f'Failed to generate "{question["ref"]}"' | ||
| 116 | - raise TestFactoryException(msg) from exc | ||
| 117 | - else: | ||
| 118 | - logger.info('%4d. "%s" Ok.', counter, question["ref"]) | ||
| 119 | - counter += 1 | ||
| 120 | - | ||
| 121 | - qmissing = qrefs.difference(set(self.question_factory.keys())) | ||
| 122 | - if qmissing: | ||
| 123 | - raise TestFactoryException(f'Could not find questions {qmissing}.') | ||
| 124 | - | ||
| 125 | - # ------------------------------------------------------------------------ | ||
| 126 | - def check_test_ref(self): | ||
| 127 | - '''Test must have a `ref`''' | ||
| 128 | - if 'ref' not in self: | ||
| 129 | - raise TestFactoryException('Missing "ref" in configuration!') | ||
| 130 | - if not re.match(r'^[a-zA-Z0-9_-]+$', self['ref']): | ||
| 131 | - raise TestFactoryException('Test "ref" can only contain the ' | ||
| 132 | - 'characters a-zA-Z0-9_-') | ||
| 133 | - | ||
| 134 | - def check_missing_database(self): | ||
| 135 | - '''Test must have a database''' | ||
| 136 | - if 'database' not in self: | ||
| 137 | - raise TestFactoryException('Missing "database" in configuration') | ||
| 138 | - if not path.isfile(path.expanduser(self['database'])): | ||
| 139 | - msg = f'Database "{self["database"]}" not found!' | ||
| 140 | - raise TestFactoryException(msg) | ||
| 141 | - | ||
| 142 | - def check_missing_answers_directory(self): | ||
| 143 | - '''Test must have a answers directory''' | ||
| 144 | - if 'answers_dir' not in self: | ||
| 145 | - msg = 'Missing "answers_dir" in configuration' | ||
| 146 | - raise TestFactoryException(msg) | ||
| 147 | - | ||
| 148 | - def check_answers_directory_writable(self): | ||
| 149 | - '''Answers directory must be writable''' | ||
| 150 | - testfile = path.join(path.expanduser(self['answers_dir']), 'REMOVE-ME') | ||
| 151 | - try: | ||
| 152 | - with open(testfile, 'w') as file: | ||
| 153 | - file.write('You can safely remove this file.') | ||
| 154 | - except OSError as exc: | ||
| 155 | - msg = f'Cannot write answers to directory "{self["answers_dir"]}"' | ||
| 156 | - raise TestFactoryException(msg) from exc | ||
| 157 | - | ||
| 158 | - def check_questions_directory(self): | ||
| 159 | - '''Check if questions directory is missing or not accessible.''' | ||
| 160 | - if 'questions_dir' not in self: | ||
| 161 | - logger.warning('Missing "questions_dir". Using "%s"', | ||
| 162 | - path.abspath(path.curdir)) | ||
| 163 | - self['questions_dir'] = path.curdir | ||
| 164 | - elif not path.isdir(path.expanduser(self['questions_dir'])): | ||
| 165 | - raise TestFactoryException(f'Can\'t find questions directory ' | ||
| 166 | - f'"{self["questions_dir"]}"') | ||
| 167 | - | ||
| 168 | - def check_import_files(self): | ||
| 169 | - '''Check if there are files to import (with questions)''' | ||
| 170 | - if 'files' not in self: | ||
| 171 | - msg = ('Missing "files" in configuration with the list of ' | ||
| 172 | - 'question files to import!') | ||
| 173 | - raise TestFactoryException(msg) | ||
| 174 | - | ||
| 175 | - if isinstance(self['files'], str): | ||
| 176 | - self['files'] = [self['files']] | ||
| 177 | - | ||
| 178 | - def check_question_list(self): | ||
| 179 | - '''normalize question list''' | ||
| 180 | - if 'questions' not in self: | ||
| 181 | - raise TestFactoryException('Missing "questions" in configuration') | ||
| 182 | - | ||
| 183 | - for i, question in enumerate(self['questions']): | ||
| 184 | - # normalize question to a dict and ref to a list of references | ||
| 185 | - if isinstance(question, str): # e.g., - some_ref | ||
| 186 | - question = {'ref': [question]} # becomes - ref: [some_ref] | ||
| 187 | - elif isinstance(question, dict) and isinstance(question['ref'], str): | ||
| 188 | - question['ref'] = [question['ref']] | ||
| 189 | - elif isinstance(question, list): | ||
| 190 | - question = {'ref': [str(a) for a in question]} | ||
| 191 | - | ||
| 192 | - self['questions'][i] = question | ||
| 193 | - | ||
| 194 | - def check_missing_title(self): | ||
| 195 | - '''Warns if title is missing''' | ||
| 196 | - if not self['title']: | ||
| 197 | - logger.warning('Title is undefined!') | ||
| 198 | - | ||
| 199 | - def check_grade_scaling(self): | ||
| 200 | - '''Just informs the scale limits''' | ||
| 201 | - if 'scale_points' in self: | ||
| 202 | - msg = ('*** DEPRECATION WARNING: *** scale_points, scale_min, ' | ||
| 203 | - 'scale_max were replaced by "scale: [min, max]".') | ||
| 204 | - logger.warning(msg) | ||
| 205 | - self['scale'] = [self['scale_min'], self['scale_max']] | ||
| 206 | - | ||
| 207 | - | ||
| 208 | - # ------------------------------------------------------------------------ | ||
| 209 | - def sanity_checks(self): | ||
| 210 | - ''' | ||
| 211 | - Checks for valid keys and sets default values. | ||
| 212 | - Also checks if some files and directories exist | ||
| 213 | - ''' | ||
| 214 | - self.check_test_ref() | ||
| 215 | - self.check_missing_database() | ||
| 216 | - self.check_missing_answers_directory() | ||
| 217 | - self.check_answers_directory_writable() | ||
| 218 | - self.check_questions_directory() | ||
| 219 | - self.check_import_files() | ||
| 220 | - self.check_question_list() | ||
| 221 | - self.check_missing_title() | ||
| 222 | - self.check_grade_scaling() | ||
| 223 | - | ||
| 224 | - # ------------------------------------------------------------------------ | ||
| 225 | - async def generate(self): | ||
| 226 | - ''' | ||
| 227 | - Given a dictionary with a student dict {'name':'john', 'number': 123} | ||
| 228 | - returns instance of Test() for that particular student | ||
| 229 | - ''' | ||
| 230 | - | ||
| 231 | - # make list of questions | ||
| 232 | - questions = [] | ||
| 233 | - qnum = 1 # track question number | ||
| 234 | - nerr = 0 # count errors during questions generation | ||
| 235 | - | ||
| 236 | - for qlist in self['questions']: | ||
| 237 | - # choose list of question variants | ||
| 238 | - choose = qlist.get('choose', 1) | ||
| 239 | - qrefs = random.sample(qlist['ref'], k=choose) | ||
| 240 | - | ||
| 241 | - for qref in qrefs: | ||
| 242 | - # generate instance of question | ||
| 243 | - try: | ||
| 244 | - question = await self.question_factory[qref].gen_async() | ||
| 245 | - except QuestionException: | ||
| 246 | - logger.error('Can\'t generate question "%s". Skipping.', qref) | ||
| 247 | - nerr += 1 | ||
| 248 | - continue | ||
| 249 | - | ||
| 250 | - # some defaults | ||
| 251 | - if question['type'] in ('information', 'success', 'warning', | ||
| 252 | - 'alert'): | ||
| 253 | - question['points'] = qlist.get('points', 0.0) | ||
| 254 | - else: | ||
| 255 | - question['points'] = qlist.get('points', 1.0) | ||
| 256 | - question['number'] = qnum # counter for non informative panels | ||
| 257 | - qnum += 1 | ||
| 258 | - | ||
| 259 | - questions.append(question) | ||
| 260 | - | ||
| 261 | - # setup scale | ||
| 262 | - total_points = sum(q['points'] for q in questions) | ||
| 263 | - | ||
| 264 | - if total_points > 0: | ||
| 265 | - # normalize question points to scale | ||
| 266 | - if self['scale'] is not None: | ||
| 267 | - scale_min, scale_max = self['scale'] | ||
| 268 | - for question in questions: | ||
| 269 | - question['points'] *= (scale_max - scale_min) / total_points | ||
| 270 | - else: | ||
| 271 | - self['scale'] = [0, total_points] | ||
| 272 | - else: | ||
| 273 | - logger.warning('Total points is **ZERO**.') | ||
| 274 | - if self['scale'] is None: | ||
| 275 | - self['scale'] = [0, 20] # default | ||
| 276 | - | ||
| 277 | - if nerr > 0: | ||
| 278 | - logger.error('%s errors found!', nerr) | ||
| 279 | - | ||
| 280 | - # copy these from the test configuratoin to each test instance | ||
| 281 | - inherit = {'ref', 'title', 'database', 'answers_dir', | ||
| 282 | - 'questions_dir', 'files', | ||
| 283 | - 'duration', 'autosubmit', | ||
| 284 | - 'scale', 'show_points', | ||
| 285 | - 'show_ref', 'debug', } | ||
| 286 | - # NOT INCLUDED: testfile, allow_all, review | ||
| 287 | - | ||
| 288 | - return Test({'questions': questions, **{k:self[k] for k in inherit}}) | ||
| 289 | - | ||
| 290 | - # ------------------------------------------------------------------------ | ||
| 291 | - def __repr__(self): | ||
| 292 | - testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) | ||
| 293 | - return '{\n' + testsettings + '\n}' | ||
| 294 | - | ||
| 295 | - | ||
| 296 | -# ============================================================================ | ||
| 297 | class Test(dict): | 17 | class Test(dict): |
| 298 | ''' | 18 | ''' |
| 299 | Each instance Test() is a concrete test of a single student. | 19 | Each instance Test() is a concrete test of a single student. |
| 300 | ''' | 20 | ''' |
| 301 | 21 | ||
| 302 | # ------------------------------------------------------------------------ | 22 | # ------------------------------------------------------------------------ |
| 303 | - # def __init__(self, d): | ||
| 304 | - # super().__init__(d) | 23 | + def __init__(self, d): |
| 24 | + super().__init__(d) | ||
| 25 | + self['grade'] = nan | ||
| 26 | + self['comment'] = '' | ||
| 305 | 27 | ||
| 306 | # ------------------------------------------------------------------------ | 28 | # ------------------------------------------------------------------------ |
| 307 | - def start(self, student): | 29 | + def start(self, student: dict) -> None: |
| 308 | ''' | 30 | ''' |
| 309 | Write student id in the test and register start time | 31 | Write student id in the test and register start time |
| 310 | ''' | 32 | ''' |
| @@ -312,59 +34,75 @@ class Test(dict): | @@ -312,59 +34,75 @@ class Test(dict): | ||
| 312 | self['start_time'] = datetime.now() | 34 | self['start_time'] = datetime.now() |
| 313 | self['finish_time'] = None | 35 | self['finish_time'] = None |
| 314 | self['state'] = 'ACTIVE' | 36 | self['state'] = 'ACTIVE' |
| 315 | - self['comment'] = '' | ||
| 316 | 37 | ||
| 317 | # ------------------------------------------------------------------------ | 38 | # ------------------------------------------------------------------------ |
| 318 | - def reset_answers(self): | 39 | + def reset_answers(self) -> None: |
| 319 | '''Removes all answers from the test (clean)''' | 40 | '''Removes all answers from the test (clean)''' |
| 320 | for question in self['questions']: | 41 | for question in self['questions']: |
| 321 | question['answer'] = None | 42 | question['answer'] = None |
| 322 | 43 | ||
| 323 | # ------------------------------------------------------------------------ | 44 | # ------------------------------------------------------------------------ |
| 324 | - def update_answer(self, ref, ans): | 45 | + def update_answer(self, ref: str, ans) -> None: |
| 325 | '''updates one answer in the test''' | 46 | '''updates one answer in the test''' |
| 326 | self['questions'][ref].set_answer(ans) | 47 | self['questions'][ref].set_answer(ans) |
| 327 | 48 | ||
| 328 | # ------------------------------------------------------------------------ | 49 | # ------------------------------------------------------------------------ |
| 329 | - def update_answers(self, answers_dict): | 50 | + def submit(self, answers_dict) -> None: |
| 330 | ''' | 51 | ''' |
| 331 | Given a dictionary ans={'ref': 'some answer'} updates the answers of | 52 | Given a dictionary ans={'ref': 'some answer'} updates the answers of |
| 332 | multiple questions in the test. | 53 | multiple questions in the test. |
| 333 | Only affects the questions referred in the dictionary. | 54 | Only affects the questions referred in the dictionary. |
| 334 | ''' | 55 | ''' |
| 56 | + self['finish_time'] = datetime.now() | ||
| 335 | for ref, ans in answers_dict.items(): | 57 | for ref, ans in answers_dict.items(): |
| 336 | self['questions'][ref].set_answer(ans) | 58 | self['questions'][ref].set_answer(ans) |
| 337 | - # self['questions'][ref]['answer'] = ans | 59 | + self['state'] = 'SUBMITTED' |
| 338 | 60 | ||
| 339 | # ------------------------------------------------------------------------ | 61 | # ------------------------------------------------------------------------ |
| 340 | - async def correct(self): | 62 | + async def correct_async(self) -> None: |
| 341 | '''Corrects all the answers of the test and computes the final grade''' | 63 | '''Corrects all the answers of the test and computes the final grade''' |
| 342 | - self['finish_time'] = datetime.now() | ||
| 343 | - self['state'] = 'FINISHED' | ||
| 344 | - | ||
| 345 | grade = 0.0 | 64 | grade = 0.0 |
| 346 | for question in self['questions']: | 65 | for question in self['questions']: |
| 347 | await question.correct_async() | 66 | await question.correct_async() |
| 348 | grade += question['grade'] * question['points'] | 67 | grade += question['grade'] * question['points'] |
| 349 | logger.debug('Correcting %30s: %3g%%', | 68 | logger.debug('Correcting %30s: %3g%%', |
| 350 | - question["ref"], question["grade"]*100) | 69 | + question['ref'], question['grade']*100) |
| 70 | + | ||
| 71 | + # truncate to avoid negative final grade and adjust scale | ||
| 72 | + self['grade'] = max(0.0, grade) + self['scale'][0] | ||
| 73 | + self['state'] = 'CORRECTED' | ||
| 74 | + | ||
| 75 | + # ------------------------------------------------------------------------ | ||
| 76 | + def correct(self) -> None: | ||
| 77 | + '''Corrects all the answers of the test and computes the final grade''' | ||
| 78 | + grade = 0.0 | ||
| 79 | + for question in self['questions']: | ||
| 80 | + question.correct() | ||
| 81 | + grade += question['grade'] * question['points'] | ||
| 82 | + logger.debug('Correcting %30s: %3g%%', | ||
| 83 | + question['ref'], question['grade']*100) | ||
| 351 | 84 | ||
| 352 | # truncate to avoid negative final grade and adjust scale | 85 | # truncate to avoid negative final grade and adjust scale |
| 353 | self['grade'] = max(0.0, grade) + self['scale'][0] | 86 | self['grade'] = max(0.0, grade) + self['scale'][0] |
| 354 | - return self['grade'] | 87 | + self['state'] = 'CORRECTED' |
| 355 | 88 | ||
| 356 | # ------------------------------------------------------------------------ | 89 | # ------------------------------------------------------------------------ |
| 357 | - def giveup(self): | 90 | + def giveup(self) -> None: |
| 358 | '''Test is marqued as QUIT and is not corrected''' | 91 | '''Test is marqued as QUIT and is not corrected''' |
| 359 | self['finish_time'] = datetime.now() | 92 | self['finish_time'] = datetime.now() |
| 360 | self['state'] = 'QUIT' | 93 | self['state'] = 'QUIT' |
| 361 | self['grade'] = 0.0 | 94 | self['grade'] = 0.0 |
| 362 | - logger.info('Student %s: gave up.', self["student"]["number"]) | ||
| 363 | - return self['grade'] | ||
| 364 | 95 | ||
| 365 | # ------------------------------------------------------------------------ | 96 | # ------------------------------------------------------------------------ |
| 366 | - def __str__(self): | ||
| 367 | - return ('Test:\n' | ||
| 368 | - f' student: {self.get("student", "--")}\n' | ||
| 369 | - f' start_time: {self.get("start_time", "--")}\n' | ||
| 370 | - f' questions: {", ".join(q["ref"] for q in self["questions"])}\n') | 97 | + def save_json(self, pathfile) -> None: |
| 98 | + '''save test in JSON format''' | ||
| 99 | + with open(pathfile, 'w') as file: | ||
| 100 | + json.dump(self, file, indent=2, default=str) # str for datetime | ||
| 101 | + | ||
| 102 | + # ------------------------------------------------------------------------ | ||
| 103 | + def __str__(self) -> str: | ||
| 104 | + return '\n'.join([f'{k}: {v}' for k,v in self.items()]) | ||
| 105 | + # return ('Test:\n' | ||
| 106 | + # f' student: {self.get("student", "--")}\n' | ||
| 107 | + # f' start_time: {self.get("start_time", "--")}\n' | ||
| 108 | + # f' questions: {", ".join(q["ref"] for q in self["questions"])}\n') |
| @@ -0,0 +1,341 @@ | @@ -0,0 +1,341 @@ | ||
| 1 | +''' | ||
| 2 | +TestFactory - generates tests for students | ||
| 3 | +''' | ||
| 4 | + | ||
| 5 | +# python standard library | ||
| 6 | +from os import path | ||
| 7 | +import random | ||
| 8 | +import logging | ||
| 9 | +import re | ||
| 10 | +from typing import Any, Dict | ||
| 11 | + | ||
| 12 | +# this project | ||
| 13 | +from perguntations.questions import QFactory, QuestionException | ||
| 14 | +from perguntations.test import Test | ||
| 15 | +from perguntations.tools import load_yaml | ||
| 16 | + | ||
| 17 | +# Logger configuration | ||
| 18 | +logger = logging.getLogger(__name__) | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +# ============================================================================ | ||
| 22 | +class TestFactoryException(Exception): | ||
| 23 | + '''exception raised in this module''' | ||
| 24 | + | ||
| 25 | + | ||
| 26 | +# ============================================================================ | ||
| 27 | +class TestFactory(dict): | ||
| 28 | + ''' | ||
| 29 | + Each instance of TestFactory() is a test generator. | ||
| 30 | + For example, if we want to serve two different tests, then we need two | ||
| 31 | + instances of TestFactory(), one for each test. | ||
| 32 | + ''' | ||
| 33 | + | ||
| 34 | + # ------------------------------------------------------------------------ | ||
| 35 | + def __init__(self, conf: Dict[str, Any]) -> None: | ||
| 36 | + ''' | ||
| 37 | + Loads configuration from yaml file, then overrides some configurations | ||
| 38 | + using the conf argument. | ||
| 39 | + Base questions are added to a pool of questions factories. | ||
| 40 | + ''' | ||
| 41 | + | ||
| 42 | + # --- set test defaults and then use given configuration | ||
| 43 | + super().__init__({ # defaults | ||
| 44 | + 'title': '', | ||
| 45 | + 'show_points': True, | ||
| 46 | + 'scale': None, | ||
| 47 | + 'duration': 0, # 0=infinite | ||
| 48 | + 'autosubmit': False, | ||
| 49 | + 'autocorrect': True, | ||
| 50 | + 'debug': False, | ||
| 51 | + 'show_ref': False, | ||
| 52 | + }) | ||
| 53 | + self.update(conf) | ||
| 54 | + | ||
| 55 | + # --- 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.') | ||
| 63 | + | ||
| 64 | + # --- find refs of all questions used in the test | ||
| 65 | + qrefs = {r for qq in self['questions'] for r in qq['ref']} | ||
| 66 | + logger.info('Declared %d questions (each test uses %d).', | ||
| 67 | + len(qrefs), len(self["questions"])) | ||
| 68 | + | ||
| 69 | + # --- load and build question factories | ||
| 70 | + self['question_factory'] = {} | ||
| 71 | + | ||
| 72 | + for file in self["files"]: | ||
| 73 | + fullpath = path.normpath(path.join(self["questions_dir"], file)) | ||
| 74 | + | ||
| 75 | + logger.info('Loading "%s"...', fullpath) | ||
| 76 | + questions = load_yaml(fullpath) # , default=[]) | ||
| 77 | + | ||
| 78 | + for i, question in enumerate(questions): | ||
| 79 | + # make sure every question in the file is a dictionary | ||
| 80 | + if not isinstance(question, dict): | ||
| 81 | + msg = f'Question {i} in {file} is not a dictionary' | ||
| 82 | + raise TestFactoryException(msg) | ||
| 83 | + | ||
| 84 | + # check if ref is missing, then set to '/path/file.yaml:3' | ||
| 85 | + if 'ref' not in question: | ||
| 86 | + question['ref'] = f'{file}:{i:04}' | ||
| 87 | + logger.warning('Missing ref set to "%s"', question["ref"]) | ||
| 88 | + | ||
| 89 | + # check for duplicate refs | ||
| 90 | + if question['ref'] in self['question_factory']: | ||
| 91 | + other = self['question_factory'][question['ref']] | ||
| 92 | + otherfile = path.join(other.question['path'], | ||
| 93 | + other.question['filename']) | ||
| 94 | + msg = (f'Duplicate reference "{question["ref"]}" in files ' | ||
| 95 | + f'"{otherfile}" and "{fullpath}".') | ||
| 96 | + raise TestFactoryException(msg) | ||
| 97 | + | ||
| 98 | + # make factory only for the questions used in the test | ||
| 99 | + if question['ref'] in qrefs: | ||
| 100 | + question.update(zip(('path', 'filename', 'index'), | ||
| 101 | + 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) | ||
| 110 | + | ||
| 111 | + qmissing = qrefs.difference(set(self['question_factory'].keys())) | ||
| 112 | + if qmissing: | ||
| 113 | + raise TestFactoryException(f'Could not find questions {qmissing}.') | ||
| 114 | + | ||
| 115 | + self.check_questions() | ||
| 116 | + | ||
| 117 | + logger.info('Test factory ready. No errors found.') | ||
| 118 | + | ||
| 119 | + | ||
| 120 | + # ------------------------------------------------------------------------ | ||
| 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']] | ||
| 201 | + | ||
| 202 | + | ||
| 203 | + # ------------------------------------------------------------------------ | ||
| 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() | ||
| 218 | + | ||
| 219 | + # ------------------------------------------------------------------------ | ||
| 220 | + def check_questions(self) -> None: | ||
| 221 | + ''' | ||
| 222 | + checks if questions can be correctly generated and corrected | ||
| 223 | + ''' | ||
| 224 | + logger.info('Checking if questions can be generated and corrected...') | ||
| 225 | + for i, (qref, qfact) in enumerate(self['question_factory'].items()): | ||
| 226 | + try: | ||
| 227 | + question = qfact.generate() | ||
| 228 | + except Exception as exc: | ||
| 229 | + msg = f'Failed to generate "{qref}"' | ||
| 230 | + raise TestFactoryException(msg) from exc | ||
| 231 | + else: | ||
| 232 | + 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 i, 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', i) | ||
| 247 | + else: | ||
| 248 | + logger.error(' TEST %i IS WRONG!!!', i) | ||
| 249 | + elif 'tests_wrong' in question: | ||
| 250 | + for i, 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', i) | ||
| 260 | + else: | ||
| 261 | + logger.error(' TEST %i IS WRONG!!!', i) | ||
| 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 | + | ||
| 272 | + # ------------------------------------------------------------------------ | ||
| 273 | + async def generate(self): | ||
| 274 | + ''' | ||
| 275 | + Given a dictionary with a student dict {'name':'john', 'number': 123} | ||
| 276 | + returns instance of Test() for that particular student | ||
| 277 | + ''' | ||
| 278 | + | ||
| 279 | + # make list of questions | ||
| 280 | + questions = [] | ||
| 281 | + qnum = 1 # track question number | ||
| 282 | + nerr = 0 # count errors during questions generation | ||
| 283 | + | ||
| 284 | + for qlist in self['questions']: | ||
| 285 | + # choose list of question variants | ||
| 286 | + choose = qlist.get('choose', 1) | ||
| 287 | + qrefs = random.sample(qlist['ref'], k=choose) | ||
| 288 | + | ||
| 289 | + for qref in qrefs: | ||
| 290 | + # generate instance of question | ||
| 291 | + try: | ||
| 292 | + question = await self['question_factory'][qref].gen_async() | ||
| 293 | + except QuestionException: | ||
| 294 | + logger.error('Can\'t generate question "%s". Skipping.', qref) | ||
| 295 | + nerr += 1 | ||
| 296 | + continue | ||
| 297 | + | ||
| 298 | + # some defaults | ||
| 299 | + if question['type'] in ('information', 'success', 'warning', | ||
| 300 | + 'alert'): | ||
| 301 | + question['points'] = qlist.get('points', 0.0) | ||
| 302 | + else: | ||
| 303 | + question['points'] = qlist.get('points', 1.0) | ||
| 304 | + question['number'] = qnum # counter for non informative panels | ||
| 305 | + qnum += 1 | ||
| 306 | + | ||
| 307 | + questions.append(question) | ||
| 308 | + | ||
| 309 | + # setup scale | ||
| 310 | + total_points = sum(q['points'] for q in questions) | ||
| 311 | + | ||
| 312 | + if total_points > 0: | ||
| 313 | + # normalize question points to scale | ||
| 314 | + if self['scale'] is not None: | ||
| 315 | + scale_min, scale_max = self['scale'] | ||
| 316 | + for question in questions: | ||
| 317 | + question['points'] *= (scale_max - scale_min) / total_points | ||
| 318 | + else: | ||
| 319 | + self['scale'] = [0, total_points] | ||
| 320 | + else: | ||
| 321 | + logger.warning('Total points is **ZERO**.') | ||
| 322 | + if self['scale'] is None: | ||
| 323 | + self['scale'] = [0, 20] # default | ||
| 324 | + | ||
| 325 | + if nerr > 0: | ||
| 326 | + logger.error('%s errors found!', nerr) | ||
| 327 | + | ||
| 328 | + # 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 | ||
| 335 | + | ||
| 336 | + return Test({'questions': questions, **{k:self[k] for k in inherit}}) | ||
| 337 | + | ||
| 338 | + # ------------------------------------------------------------------------ | ||
| 339 | + def __repr__(self): | ||
| 340 | + testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items()) | ||
| 341 | + return '{\n' + testsettings + '\n}' |