Commit 8b66e35c6c61c7f7caea3e0019fc9f36070d676e
1 parent
0dff80b7
Exists in
master
and in
1 other branch
changes:
- list of allowed students given in a file (new command line option) - pregenerate tests for allowed students - random sample questions from a list specified in the test configuration. - popper.js removed. its provided by bootstrap.bundle
Showing
8 changed files
with
129 additions
and
77 deletions
Show diff stats
BUGS.md
| 1 | 1 | ||
| 2 | # BUGS | 2 | # BUGS |
| 3 | 3 | ||
| 4 | +- reload do teste recomeça a contagem no inicio do tempo. | ||
| 4 | - 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. | 5 | - 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. |
| 5 | - 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). | 6 | - 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). |
| 6 | - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>. | 7 | - codigo `hello world` nao esta a preservar o whitespace. O renderer de markdown gera a tag <code> que não preserva whitespace. Necessario adicionar <pre>. |
| @@ -8,6 +9,7 @@ | @@ -8,6 +9,7 @@ | ||
| 8 | - a revisao do teste não mostra as imagens. | 9 | - a revisao do teste não mostra as imagens. |
| 9 | - Test.reset_answers() unused. | 10 | - Test.reset_answers() unused. |
| 10 | - teste nao esta a mostrar imagens de vez em quando.??? | 11 | - teste nao esta a mostrar imagens de vez em quando.??? |
| 12 | +- testar as perguntas todas no início do teste como o aprendizations. | ||
| 11 | 13 | ||
| 12 | # TODO | 14 | # TODO |
| 13 | 15 | ||
| @@ -17,10 +19,8 @@ | @@ -17,10 +19,8 @@ | ||
| 17 | ou usar push (websockets?) | 19 | ou usar push (websockets?) |
| 18 | - mudar ref do test para test_id (ref já é usado nas perguntas) | 20 | - mudar ref do test para test_id (ref já é usado nas perguntas) |
| 19 | - servidor ntpd no x220 para configurar a data/hora dos portateis dell | 21 | - servidor ntpd no x220 para configurar a data/hora dos portateis dell |
| 20 | -- autorização dada, mas teste não disponível até que seja dada ordem para começar. | 22 | +- sala de espera: autorização dada, mas teste não disponível até que seja dada ordem para começar. |
| 21 | - alunos com necessidades especiais nao podem ter autosubmit. ter um autosubmit_exceptions: ['123', '456'] | 23 | - alunos com necessidades especiais nao podem ter autosubmit. ter um autosubmit_exceptions: ['123', '456'] |
| 22 | -- mostrar unfocus e window area em /admin | ||
| 23 | -- testar as perguntas todas no início do teste. | ||
| 24 | - submissao fazer um post ajax? | 24 | - submissao fazer um post ajax? |
| 25 | - adicionar opcao para eliminar um teste em curso. | 25 | - adicionar opcao para eliminar um teste em curso. |
| 26 | - enviar resposta de cada pergunta individualmente. | 26 | - enviar resposta de cada pergunta individualmente. |
| @@ -52,13 +52,14 @@ ou usar push (websockets?) | @@ -52,13 +52,14 @@ ou usar push (websockets?) | ||
| 52 | - se ocorrer um erro na correcçao avisar aluno para contactar o professor. | 52 | - se ocorrer um erro na correcçao avisar aluno para contactar o professor. |
| 53 | - abrir o teste numa janela maximizada e que nao permite que o aluno a redimensione/mova? | 53 | - abrir o teste numa janela maximizada e que nao permite que o aluno a redimensione/mova? |
| 54 | - detectar scroll e enviar posição para servidor (analise de scroll para detectar copianço? ou simplesmente para analisar como os alunos percorrem o teste) | 54 | - detectar scroll e enviar posição para servidor (analise de scroll para detectar copianço? ou simplesmente para analisar como os alunos percorrem o teste) |
| 55 | -- aviso na pagina principal para quem usa browser da treta | 55 | +- aviso na pagina principal para quem usa browser incompativel ou settings esquisitos... Apos login pode ser enviado e submetido um exemplo de teste para verificar se browser consegue submeter? ha alunos com javascript bloqueado? |
| 56 | - criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput | 56 | - criar perguntas de outros tipos, e.g. associação, ordenação, varios textinput |
| 57 | -- perguntas para professor corrigir mais tarde. | 57 | +- perguntas para professor corrigir mais tarde. permitir que review possa alterar as notas |
| 58 | - fazer uma calculadora javascript e por no menu. surge como modal | 58 | - fazer uma calculadora javascript e por no menu. surge como modal |
| 59 | 59 | ||
| 60 | # FIXED | 60 | # FIXED |
| 61 | 61 | ||
| 62 | +- mostrar unfocus e window area em /admin | ||
| 62 | - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) | 63 | - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) |
| 63 | - botao de autorizar desliga-se, fazer debounce. | 64 | - botao de autorizar desliga-se, fazer debounce. |
| 64 | - link na pagina com a nota para voltar ao principio. | 65 | - link na pagina com a nota para voltar ao principio. |
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.dev1' | 35 | +APP_VERSION = '2020.11.dev2' |
| 36 | APP_DESCRIPTION = __doc__ | 36 | APP_DESCRIPTION = __doc__ |
| 37 | 37 | ||
| 38 | __author__ = 'Miguel Barão' | 38 | __author__ = 'Miguel Barão' |
perguntations/app.py
| @@ -50,6 +50,7 @@ async def hash_password(password): | @@ -50,6 +50,7 @@ async def hash_password(password): | ||
| 50 | 50 | ||
| 51 | 51 | ||
| 52 | # ============================================================================ | 52 | # ============================================================================ |
| 53 | +# main application | ||
| 53 | # ============================================================================ | 54 | # ============================================================================ |
| 54 | class App(): | 55 | class App(): |
| 55 | ''' | 56 | ''' |
| @@ -66,10 +67,10 @@ class App(): | @@ -66,10 +67,10 @@ class App(): | ||
| 66 | 67 | ||
| 67 | # ------------------------------------------------------------------------ | 68 | # ------------------------------------------------------------------------ |
| 68 | @contextmanager | 69 | @contextmanager |
| 69 | - def db_session(self): | 70 | + def _db_session(self): |
| 70 | ''' | 71 | ''' |
| 71 | helper to manage db sessions using the `with` statement, for example: | 72 | helper to manage db sessions using the `with` statement, for example: |
| 72 | - with self.db_session() as s: s.query(...) | 73 | + with self._db_session() as s: s.query(...) |
| 73 | ''' | 74 | ''' |
| 74 | session = self.Session() | 75 | session = self.Session() |
| 75 | try: | 76 | try: |
| @@ -98,23 +99,29 @@ class App(): | @@ -98,23 +99,29 @@ class App(): | ||
| 98 | engine = create_engine(database, echo=False) | 99 | engine = create_engine(database, echo=False) |
| 99 | self.Session = sessionmaker(bind=engine) | 100 | self.Session = sessionmaker(bind=engine) |
| 100 | try: | 101 | try: |
| 101 | - with self.db_session() as sess: | 102 | + with self._db_session() as sess: |
| 102 | num = sess.query(Student).filter(Student.id != '0').count() | 103 | num = sess.query(Student).filter(Student.id != '0').count() |
| 103 | except Exception as exc: | 104 | except Exception as exc: |
| 104 | raise AppException(f'Database unusable {dbfile}.') from exc | 105 | raise AppException(f'Database unusable {dbfile}.') from exc |
| 105 | logger.info('Database "%s" has %s students.', dbfile, num) | 106 | logger.info('Database "%s" has %s students.', dbfile, num) |
| 106 | 107 | ||
| 107 | - # pre-generate tests | ||
| 108 | - logger.info('Generating tests for %d students...', num) | ||
| 109 | - self._pregenerate_tests(num) | ||
| 110 | - logger.info('Tests done.') | ||
| 111 | - | ||
| 112 | # command line option --allow-all | 108 | # command line option --allow-all |
| 113 | if conf['allow_all']: | 109 | if conf['allow_all']: |
| 114 | self.allow_all_students() | 110 | self.allow_all_students() |
| 111 | + elif conf['allow_list'] is not None: | ||
| 112 | + self.allow_list(conf['allow_list']) | ||
| 115 | else: | 113 | else: |
| 116 | logger.info('Students not yet allowed to login.') | 114 | logger.info('Students not yet allowed to login.') |
| 117 | 115 | ||
| 116 | + # pre-generate tests | ||
| 117 | + | ||
| 118 | + if self.allowed: | ||
| 119 | + logger.info('Generating %d tests. May take awhile...', | ||
| 120 | + len(self.allowed)) | ||
| 121 | + self._pregenerate_tests(len(self.allowed)) | ||
| 122 | + else: | ||
| 123 | + logger.info('No tests were generated.') | ||
| 124 | + | ||
| 118 | # ------------------------------------------------------------------------ | 125 | # ------------------------------------------------------------------------ |
| 119 | async def login(self, uid, try_pw): | 126 | async def login(self, uid, try_pw): |
| 120 | '''login authentication''' | 127 | '''login authentication''' |
| @@ -123,7 +130,7 @@ class App(): | @@ -123,7 +130,7 @@ class App(): | ||
| 123 | return False | 130 | return False |
| 124 | 131 | ||
| 125 | # get name+password from db | 132 | # get name+password from db |
| 126 | - with self.db_session() as sess: | 133 | + with self._db_session() as sess: |
| 127 | name, password = sess.query(Student.name, Student.password)\ | 134 | name, password = sess.query(Student.name, Student.password)\ |
| 128 | .filter_by(id=uid)\ | 135 | .filter_by(id=uid)\ |
| 129 | .one() | 136 | .one() |
| @@ -183,31 +190,34 @@ class App(): | @@ -183,31 +190,34 @@ class App(): | ||
| 183 | 190 | ||
| 184 | # ------------------------------------------------------------------------ | 191 | # ------------------------------------------------------------------------ |
| 185 | def _pregenerate_tests(self, num): | 192 | def _pregenerate_tests(self, num): |
| 186 | - for _ in range(num): | ||
| 187 | - event_loop = asyncio.get_event_loop() | ||
| 188 | - test = event_loop.run_until_complete(self.testfactory.generate()) | ||
| 189 | - self.pregenerated_tests.append(test) | 193 | + 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 += [ | ||
| 199 | + event_loop.run_until_complete(self.testfactory.generate()) | ||
| 200 | + for _ in range(num)] | ||
| 190 | 201 | ||
| 191 | # ------------------------------------------------------------------------ | 202 | # ------------------------------------------------------------------------ |
| 192 | async def generate_test(self, uid): | 203 | async def generate_test(self, uid): |
| 193 | - '''generate a test for a given student''' | ||
| 194 | - if uid in self.online: | ||
| 195 | - try: | ||
| 196 | - test = self.pregenerated_tests.pop() | ||
| 197 | - except IndexError: | ||
| 198 | - logger.info('"%s" generating new test.', uid) | ||
| 199 | - test = await self.testfactory.generate() # student_id) FIXME | ||
| 200 | - else: | ||
| 201 | - logger.info('"%s" using pregenerated test.', uid) | 204 | + '''generate a test for a given student. the student must be online''' |
| 205 | + | ||
| 206 | + student_id = self.online[uid]['student'] # {'name': ?, 'number': ?} | ||
| 202 | 207 | ||
| 203 | - student_id = self.online[uid]['student'] # {number, name} | ||
| 204 | - test.start(student_id) | ||
| 205 | - self.online[uid]['test'] = test | 208 | + try: |
| 209 | + test = self.pregenerated_tests.pop() | ||
| 210 | + except IndexError: | ||
| 211 | + logger.info('"%s" generating new test...', uid) | ||
| 212 | + test = await self.testfactory.generate() | ||
| 206 | logger.info('"%s" test is ready.', uid) | 213 | logger.info('"%s" test is ready.', uid) |
| 207 | - return self.online[uid]['test'] | 214 | + else: |
| 215 | + logger.info('"%s" using a pregenerated test.', uid) | ||
| 208 | 216 | ||
| 209 | - # this implies an error in the program, code should be unreachable! | ||
| 210 | - logger.critical('"%s" is offline, can\'t generate test', uid) | 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'] | ||
| 211 | 221 | ||
| 212 | # ------------------------------------------------------------------------ | 222 | # ------------------------------------------------------------------------ |
| 213 | async def correct_test(self, uid, ans): | 223 | async def correct_test(self, uid, ans): |
| @@ -236,7 +246,7 @@ class App(): | @@ -236,7 +246,7 @@ class App(): | ||
| 236 | logger.info('"%s" saved JSON.', uid) | 246 | logger.info('"%s" saved JSON.', uid) |
| 237 | 247 | ||
| 238 | # --- insert test and questions into database | 248 | # --- insert test and questions into database |
| 239 | - with self.db_session() as sess: | 249 | + with self._db_session() as sess: |
| 240 | sess.add(Test( | 250 | sess.add(Test( |
| 241 | ref=test['ref'], | 251 | ref=test['ref'], |
| 242 | title=test['title'], | 252 | title=test['title'], |
| @@ -273,7 +283,7 @@ class App(): | @@ -273,7 +283,7 @@ class App(): | ||
| 273 | test.save_json(fpath) | 283 | test.save_json(fpath) |
| 274 | 284 | ||
| 275 | # insert test into database | 285 | # insert test into database |
| 276 | - with self.db_session() as sess: | 286 | + with self._db_session() as sess: |
| 277 | sess.add(Test(ref=test['ref'], | 287 | sess.add(Test(ref=test['ref'], |
| 278 | title=test['title'], | 288 | title=test['title'], |
| 279 | grade=test['grade'], | 289 | grade=test['grade'], |
| @@ -309,7 +319,7 @@ class App(): | @@ -309,7 +319,7 @@ class App(): | ||
| 309 | '''generates a CSV with the grades of the test''' | 319 | '''generates a CSV with the grades of the test''' |
| 310 | test_id = self.testfactory['ref'] | 320 | test_id = self.testfactory['ref'] |
| 311 | 321 | ||
| 312 | - with self.db_session() as sess: | 322 | + with self._db_session() as sess: |
| 313 | grades = sess.query(Question.student_id, Question.starttime, | 323 | grades = sess.query(Question.student_id, Question.starttime, |
| 314 | Question.ref, Question.grade)\ | 324 | Question.ref, Question.grade)\ |
| 315 | .filter(Question.test_id == test_id)\ | 325 | .filter(Question.test_id == test_id)\ |
| @@ -338,7 +348,7 @@ class App(): | @@ -338,7 +348,7 @@ class App(): | ||
| 338 | 348 | ||
| 339 | def get_test_csv(self): | 349 | def get_test_csv(self): |
| 340 | '''generates a CSV with the grades of the test''' | 350 | '''generates a CSV with the grades of the test''' |
| 341 | - with self.db_session() as sess: | 351 | + with self._db_session() as sess: |
| 342 | grades = sess.query(Test.student_id, Test.grade, | 352 | grades = sess.query(Test.student_id, Test.grade, |
| 343 | Test.starttime, Test.finishtime)\ | 353 | Test.starttime, Test.finishtime)\ |
| 344 | .filter(Test.ref == self.testfactory['ref'])\ | 354 | .filter(Test.ref == self.testfactory['ref'])\ |
| @@ -360,21 +370,21 @@ class App(): | @@ -360,21 +370,21 @@ class App(): | ||
| 360 | 370 | ||
| 361 | def get_student_grades_from_all_tests(self, uid): | 371 | def get_student_grades_from_all_tests(self, uid): |
| 362 | '''get grades of student from all tests''' | 372 | '''get grades of student from all tests''' |
| 363 | - with self.db_session() as sess: | 373 | + with self._db_session() as sess: |
| 364 | return sess.query(Test.title, Test.grade, Test.finishtime)\ | 374 | return sess.query(Test.title, Test.grade, Test.finishtime)\ |
| 365 | .filter_by(student_id=uid)\ | 375 | .filter_by(student_id=uid)\ |
| 366 | .order_by(Test.finishtime) | 376 | .order_by(Test.finishtime) |
| 367 | 377 | ||
| 368 | def get_json_filename_of_test(self, test_id): | 378 | def get_json_filename_of_test(self, test_id): |
| 369 | '''get JSON filename from database given the test_id''' | 379 | '''get JSON filename from database given the test_id''' |
| 370 | - with self.db_session() as sess: | 380 | + with self._db_session() as sess: |
| 371 | return sess.query(Test.filename)\ | 381 | return sess.query(Test.filename)\ |
| 372 | .filter_by(id=test_id)\ | 382 | .filter_by(id=test_id)\ |
| 373 | .scalar() | 383 | .scalar() |
| 374 | 384 | ||
| 375 | def get_student_grades_from_test(self, uid, testid): | 385 | def get_student_grades_from_test(self, uid, testid): |
| 376 | '''get grades of student for a given testid''' | 386 | '''get grades of student for a given testid''' |
| 377 | - with self.db_session() as sess: | 387 | + with self._db_session() as sess: |
| 378 | return sess.query(Test.grade, Test.finishtime, Test.id)\ | 388 | return sess.query(Test.grade, Test.finishtime, Test.id)\ |
| 379 | .filter_by(student_id=uid)\ | 389 | .filter_by(student_id=uid)\ |
| 380 | .filter_by(ref=testid)\ | 390 | .filter_by(ref=testid)\ |
| @@ -399,7 +409,7 @@ class App(): | @@ -399,7 +409,7 @@ class App(): | ||
| 399 | # --- private methods ---------------------------------------------------- | 409 | # --- private methods ---------------------------------------------------- |
| 400 | def _get_all_students(self): | 410 | def _get_all_students(self): |
| 401 | '''get all students from database''' | 411 | '''get all students from database''' |
| 402 | - with self.db_session() as sess: | 412 | + with self._db_session() as sess: |
| 403 | return sess.query(Student.id, Student.name, Student.password)\ | 413 | return sess.query(Student.id, Student.name, Student.password)\ |
| 404 | .filter(Student.id != '0')\ | 414 | .filter(Student.id != '0')\ |
| 405 | .order_by(Student.id) | 415 | .order_by(Student.id) |
| @@ -433,13 +443,33 @@ class App(): | @@ -433,13 +443,33 @@ class App(): | ||
| 433 | '''allow all students to login''' | 443 | '''allow all students to login''' |
| 434 | all_students = self._get_all_students() | 444 | all_students = self._get_all_students() |
| 435 | self.allowed.update(s[0] for s in all_students) | 445 | self.allowed.update(s[0] for s in all_students) |
| 436 | - logger.info('Allowed all students.') | 446 | + logger.info('Allowed all %d students.', len(self.allowed)) |
| 437 | 447 | ||
| 438 | def deny_all_students(self): | 448 | def deny_all_students(self): |
| 439 | '''deny all students to login''' | 449 | '''deny all students to login''' |
| 440 | logger.info('Denying all students...') | 450 | logger.info('Denying all students...') |
| 441 | self.allowed.clear() | 451 | self.allowed.clear() |
| 442 | 452 | ||
| 453 | + def allow_list(self, filename): | ||
| 454 | + '''allow students listed in file (one number per line)''' | ||
| 455 | + try: | ||
| 456 | + with open(filename, 'r') as file: | ||
| 457 | + allowed_in_file = {s.strip() for s in file} - {''} | ||
| 458 | + except Exception as exc: | ||
| 459 | + error_msg = f'Cannot read file {filename}' | ||
| 460 | + logger.critical(error_msg) | ||
| 461 | + raise AppException(error_msg) from exc | ||
| 462 | + | ||
| 463 | + enrolled = set(s[0] for s in self._get_all_students()) # in database | ||
| 464 | + self.allowed.update(allowed_in_file & enrolled) | ||
| 465 | + logger.info('Allowed %d students provided in "%s"', len(self.allowed), | ||
| 466 | + filename) | ||
| 467 | + | ||
| 468 | + not_enrolled = allowed_in_file - enrolled | ||
| 469 | + if not_enrolled: | ||
| 470 | + logger.warning(' but found students not in the database: %s', | ||
| 471 | + ', '.join(not_enrolled)) | ||
| 472 | + | ||
| 443 | def _focus_student(self, uid): | 473 | def _focus_student(self, uid): |
| 444 | '''set student in focus state''' | 474 | '''set student in focus state''' |
| 445 | self.unfocus.discard(uid) | 475 | self.unfocus.discard(uid) |
| @@ -462,7 +492,7 @@ class App(): | @@ -462,7 +492,7 @@ class App(): | ||
| 462 | '''change password on the database''' | 492 | '''change password on the database''' |
| 463 | if password: | 493 | if password: |
| 464 | password = await hash_password(password) | 494 | password = await hash_password(password) |
| 465 | - with self.db_session() as sess: | 495 | + with self._db_session() as sess: |
| 466 | student = sess.query(Student).filter_by(id=uid).one() | 496 | student = sess.query(Student).filter_by(id=uid).one() |
| 467 | student.password = password | 497 | student.password = password |
| 468 | logger.info('"%s" password updated.', uid) | 498 | logger.info('"%s" password updated.', uid) |
| @@ -470,7 +500,7 @@ class App(): | @@ -470,7 +500,7 @@ class App(): | ||
| 470 | def insert_new_student(self, uid, name): | 500 | def insert_new_student(self, uid, name): |
| 471 | '''insert new student into the database''' | 501 | '''insert new student into the database''' |
| 472 | try: | 502 | try: |
| 473 | - with self.db_session() as sess: | 503 | + with self._db_session() as sess: |
| 474 | sess.add(Student(id=uid, name=name, password='')) | 504 | sess.add(Student(id=uid, name=name, password='')) |
| 475 | except exc.SQLAlchemyError: | 505 | except exc.SQLAlchemyError: |
| 476 | logger.error('Insert failed: student %s already exists?', uid) | 506 | logger.error('Insert failed: student %s already exists?', uid) |
perguntations/main.py
| @@ -32,10 +32,14 @@ def parse_cmdline_arguments(): | @@ -32,10 +32,14 @@ def parse_cmdline_arguments(): | ||
| 32 | 'included with this software before running the server.') | 32 | 'included with this software before running the server.') |
| 33 | parser.add_argument('testfile', | 33 | parser.add_argument('testfile', |
| 34 | type=str, | 34 | type=str, |
| 35 | + # nargs='+', # TODO | ||
| 35 | help='tests in YAML format') | 36 | help='tests in YAML format') |
| 36 | parser.add_argument('--allow-all', | 37 | parser.add_argument('--allow-all', |
| 37 | action='store_true', | 38 | action='store_true', |
| 38 | help='Allow all students to login immediately') | 39 | help='Allow all students to login immediately') |
| 40 | + parser.add_argument('--allow-list', | ||
| 41 | + type=str, | ||
| 42 | + help='File with list of students to allow immediately') | ||
| 39 | parser.add_argument('--debug', | 43 | parser.add_argument('--debug', |
| 40 | action='store_true', | 44 | action='store_true', |
| 41 | help='Enable debug messages') | 45 | help='Enable debug messages') |
| @@ -46,9 +50,12 @@ def parse_cmdline_arguments(): | @@ -46,9 +50,12 @@ def parse_cmdline_arguments(): | ||
| 46 | action='store_true', | 50 | action='store_true', |
| 47 | help='Review mode: doesn\'t generate test') | 51 | help='Review mode: doesn\'t generate test') |
| 48 | parser.add_argument('--port', | 52 | parser.add_argument('--port', |
| 49 | - type=int, default=8443, | 53 | + type=int, |
| 54 | + default=8443, | ||
| 50 | help='port for the HTTPS server (default: 8443)') | 55 | help='port for the HTTPS server (default: 8443)') |
| 51 | - parser.add_argument('--version', action='version', version=APP_VERSION, | 56 | + parser.add_argument('--version', |
| 57 | + action='version', | ||
| 58 | + version=APP_VERSION, | ||
| 52 | help='Show version information and exit') | 59 | help='Show version information and exit') |
| 53 | return parser.parse_args() | 60 | return parser.parse_args() |
| 54 | 61 | ||
| @@ -119,6 +126,7 @@ def main(): | @@ -119,6 +126,7 @@ def main(): | ||
| 119 | 'testfile': args.testfile, | 126 | 'testfile': args.testfile, |
| 120 | 'debug': args.debug, | 127 | 'debug': args.debug, |
| 121 | 'allow_all': args.allow_all, | 128 | 'allow_all': args.allow_all, |
| 129 | + 'allow_list': args.allow_list, | ||
| 122 | 'show_ref': args.show_ref, | 130 | 'show_ref': args.show_ref, |
| 123 | 'review': args.review, | 131 | 'review': args.review, |
| 124 | } | 132 | } |
perguntations/serve.py
| @@ -518,6 +518,7 @@ def run_webserver(app, ssl_opt, port, debug): | @@ -518,6 +518,7 @@ def run_webserver(app, ssl_opt, port, debug): | ||
| 518 | ''' | 518 | ''' |
| 519 | 519 | ||
| 520 | # --- create web application | 520 | # --- create web application |
| 521 | + logging.info('-----------------------------------------------------------') | ||
| 521 | logging.info('Starting WebApplication (tornado)') | 522 | logging.info('Starting WebApplication (tornado)') |
| 522 | try: | 523 | try: |
| 523 | webapp = WebApplication(app, debug=debug) | 524 | webapp = WebApplication(app, debug=debug) |
perguntations/static/css/test.css
| @@ -8,6 +8,11 @@ body { | @@ -8,6 +8,11 @@ body { | ||
| 8 | background: #bbb; | 8 | background: #bbb; |
| 9 | } | 9 | } |
| 10 | 10 | ||
| 11 | +/*code { | ||
| 12 | + white-space: pre; | ||
| 13 | +} | ||
| 14 | +*/ | ||
| 15 | + | ||
| 11 | /* Hack to avoid name clash between pygments and mathjax */ | 16 | /* Hack to avoid name clash between pygments and mathjax */ |
| 12 | .MathJax .mo, | 17 | .MathJax .mo, |
| 13 | .MathJax .mi { | 18 | .MathJax .mi { |
perguntations/static/popper.js
perguntations/test.py
| @@ -48,7 +48,7 @@ class TestFactory(dict): | @@ -48,7 +48,7 @@ class TestFactory(dict): | ||
| 48 | 'duration': 0, # 0=infinite | 48 | 'duration': 0, # 0=infinite |
| 49 | 'autosubmit': False, | 49 | 'autosubmit': False, |
| 50 | 'debug': False, | 50 | 'debug': False, |
| 51 | - 'show_ref': False | 51 | + 'show_ref': False, |
| 52 | }) | 52 | }) |
| 53 | self.update(conf) | 53 | self.update(conf) |
| 54 | 54 | ||
| @@ -122,7 +122,6 @@ class TestFactory(dict): | @@ -122,7 +122,6 @@ class TestFactory(dict): | ||
| 122 | if qmissing: | 122 | if qmissing: |
| 123 | raise TestFactoryException(f'Could not find questions {qmissing}.') | 123 | raise TestFactoryException(f'Could not find questions {qmissing}.') |
| 124 | 124 | ||
| 125 | - | ||
| 126 | # ------------------------------------------------------------------------ | 125 | # ------------------------------------------------------------------------ |
| 127 | def check_test_ref(self): | 126 | def check_test_ref(self): |
| 128 | '''Test must have a `ref`''' | 127 | '''Test must have a `ref`''' |
| @@ -223,7 +222,7 @@ class TestFactory(dict): | @@ -223,7 +222,7 @@ class TestFactory(dict): | ||
| 223 | self.check_grade_scaling() | 222 | self.check_grade_scaling() |
| 224 | 223 | ||
| 225 | # ------------------------------------------------------------------------ | 224 | # ------------------------------------------------------------------------ |
| 226 | - async def generate(self): #, student): | 225 | + async def generate(self): |
| 227 | ''' | 226 | ''' |
| 228 | Given a dictionary with a student dict {'name':'john', 'number': 123} | 227 | Given a dictionary with a student dict {'name':'john', 'number': 123} |
| 229 | returns instance of Test() for that particular student | 228 | returns instance of Test() for that particular student |
| @@ -235,27 +234,29 @@ class TestFactory(dict): | @@ -235,27 +234,29 @@ class TestFactory(dict): | ||
| 235 | nerr = 0 # count errors during questions generation | 234 | nerr = 0 # count errors during questions generation |
| 236 | 235 | ||
| 237 | for qlist in self['questions']: | 236 | for qlist in self['questions']: |
| 238 | - # choose one question variant | ||
| 239 | - qref = random.choice(qlist['ref']) | ||
| 240 | - | ||
| 241 | - # generate instance of question | ||
| 242 | - try: | ||
| 243 | - question = await self.question_factory[qref].gen_async() | ||
| 244 | - except QuestionException: | ||
| 245 | - logger.error('Can\'t generate question "%s". Skipping.', qref) | ||
| 246 | - nerr += 1 | ||
| 247 | - continue | ||
| 248 | - | ||
| 249 | - # some defaults | ||
| 250 | - if question['type'] in ('information', 'success', 'warning', | ||
| 251 | - 'alert'): | ||
| 252 | - question['points'] = qlist.get('points', 0.0) | ||
| 253 | - else: | ||
| 254 | - question['points'] = qlist.get('points', 1.0) | ||
| 255 | - question['number'] = qnum # counter for non informative panels | ||
| 256 | - qnum += 1 | ||
| 257 | - | ||
| 258 | - questions.append(question) | 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) | ||
| 259 | 260 | ||
| 260 | # setup scale | 261 | # setup scale |
| 261 | total_points = sum(q['points'] for q in questions) | 262 | total_points = sum(q['points'] for q in questions) |
| @@ -320,13 +321,20 @@ class Test(dict): | @@ -320,13 +321,20 @@ class Test(dict): | ||
| 320 | question['answer'] = None | 321 | question['answer'] = None |
| 321 | 322 | ||
| 322 | # ------------------------------------------------------------------------ | 323 | # ------------------------------------------------------------------------ |
| 323 | - def update_answers(self, ans): | 324 | + def update_answer(self, ref, ans): |
| 325 | + '''updates one answer in the test''' | ||
| 326 | + self['questions'][ref].set_answer(ans) | ||
| 327 | + | ||
| 328 | + # ------------------------------------------------------------------------ | ||
| 329 | + def update_answers(self, answers_dict): | ||
| 324 | ''' | 330 | ''' |
| 325 | Given a dictionary ans={'ref': 'some answer'} updates the answers of | 331 | Given a dictionary ans={'ref': 'some answer'} updates the answers of |
| 326 | - the test. Only affects the questions referred in the dictionary. | 332 | + multiple questions in the test. |
| 333 | + Only affects the questions referred in the dictionary. | ||
| 327 | ''' | 334 | ''' |
| 328 | - for ref, answer in ans.items(): | ||
| 329 | - self['questions'][ref]['answer'] = answer | 335 | + for ref, ans in answers_dict.items(): |
| 336 | + self['questions'][ref].set_answer(ans) | ||
| 337 | + # self['questions'][ref]['answer'] = ans | ||
| 330 | 338 | ||
| 331 | # ------------------------------------------------------------------------ | 339 | # ------------------------------------------------------------------------ |
| 332 | async def correct(self): | 340 | async def correct(self): |
| @@ -351,7 +359,7 @@ class Test(dict): | @@ -351,7 +359,7 @@ class Test(dict): | ||
| 351 | self['finish_time'] = datetime.now() | 359 | self['finish_time'] = datetime.now() |
| 352 | self['state'] = 'QUIT' | 360 | self['state'] = 'QUIT' |
| 353 | self['grade'] = 0.0 | 361 | self['grade'] = 0.0 |
| 354 | - # logger.info('Student %s: gave up.', self["student"]["number"]) | 362 | + logger.info('Student %s: gave up.', self["student"]["number"]) |
| 355 | return self['grade'] | 363 | return self['grade'] |
| 356 | 364 | ||
| 357 | # ------------------------------------------------------------------------ | 365 | # ------------------------------------------------------------------------ |