Commit 8b66e35c6c61c7f7caea3e0019fc9f36070d676e

Authored by Miguel Barão
1 parent 0dff80b7
Exists in master and in 1 other branch dev

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
BUGS.md
1 1  
2 2 # BUGS
3 3  
  4 +- reload do teste recomeça a contagem no inicio do tempo.
4 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 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 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 9 - a revisao do teste não mostra as imagens.
9 10 - Test.reset_answers() unused.
10 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 14 # TODO
13 15  
... ... @@ -17,10 +19,8 @@
17 19 ou usar push (websockets?)
18 20 - mudar ref do test para test_id (ref já é usado nas perguntas)
19 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 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 24 - submissao fazer um post ajax?
25 25 - adicionar opcao para eliminar um teste em curso.
26 26 - enviar resposta de cada pergunta individualmente.
... ... @@ -52,13 +52,14 @@ ou usar push (websockets?)
52 52 - se ocorrer um erro na correcçao avisar aluno para contactar o professor.
53 53 - abrir o teste numa janela maximizada e que nao permite que o aluno a redimensione/mova?
54 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 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 58 - fazer uma calculadora javascript e por no menu. surge como modal
59 59  
60 60 # FIXED
61 61  
  62 +- mostrar unfocus e window area em /admin
62 63 - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?)
63 64 - botao de autorizar desliga-se, fazer debounce.
64 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 32 '''
33 33  
34 34 APP_NAME = 'perguntations'
35   -APP_VERSION = '2020.11.dev1'
  35 +APP_VERSION = '2020.11.dev2'
36 36 APP_DESCRIPTION = __doc__
37 37  
38 38 __author__ = 'Miguel Barão'
... ...
perguntations/app.py
... ... @@ -50,6 +50,7 @@ async def hash_password(password):
50 50  
51 51  
52 52 # ============================================================================
  53 +# main application
53 54 # ============================================================================
54 55 class App():
55 56 '''
... ... @@ -66,10 +67,10 @@ class App():
66 67  
67 68 # ------------------------------------------------------------------------
68 69 @contextmanager
69   - def db_session(self):
  70 + def _db_session(self):
70 71 '''
71 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 75 session = self.Session()
75 76 try:
... ... @@ -98,23 +99,29 @@ class App():
98 99 engine = create_engine(database, echo=False)
99 100 self.Session = sessionmaker(bind=engine)
100 101 try:
101   - with self.db_session() as sess:
  102 + with self._db_session() as sess:
102 103 num = sess.query(Student).filter(Student.id != '0').count()
103 104 except Exception as exc:
104 105 raise AppException(f'Database unusable {dbfile}.') from exc
105 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 108 # command line option --allow-all
113 109 if conf['allow_all']:
114 110 self.allow_all_students()
  111 + elif conf['allow_list'] is not None:
  112 + self.allow_list(conf['allow_list'])
115 113 else:
116 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 126 async def login(self, uid, try_pw):
120 127 '''login authentication'''
... ... @@ -123,7 +130,7 @@ class App():
123 130 return False
124 131  
125 132 # get name+password from db
126   - with self.db_session() as sess:
  133 + with self._db_session() as sess:
127 134 name, password = sess.query(Student.name, Student.password)\
128 135 .filter_by(id=uid)\
129 136 .one()
... ... @@ -183,31 +190,34 @@ class App():
183 190  
184 191 # ------------------------------------------------------------------------
185 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 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 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 223 async def correct_test(self, uid, ans):
... ... @@ -236,7 +246,7 @@ class App():
236 246 logger.info('"%s" saved JSON.', uid)
237 247  
238 248 # --- insert test and questions into database
239   - with self.db_session() as sess:
  249 + with self._db_session() as sess:
240 250 sess.add(Test(
241 251 ref=test['ref'],
242 252 title=test['title'],
... ... @@ -273,7 +283,7 @@ class App():
273 283 test.save_json(fpath)
274 284  
275 285 # insert test into database
276   - with self.db_session() as sess:
  286 + with self._db_session() as sess:
277 287 sess.add(Test(ref=test['ref'],
278 288 title=test['title'],
279 289 grade=test['grade'],
... ... @@ -309,7 +319,7 @@ class App():
309 319 '''generates a CSV with the grades of the test'''
310 320 test_id = self.testfactory['ref']
311 321  
312   - with self.db_session() as sess:
  322 + with self._db_session() as sess:
313 323 grades = sess.query(Question.student_id, Question.starttime,
314 324 Question.ref, Question.grade)\
315 325 .filter(Question.test_id == test_id)\
... ... @@ -338,7 +348,7 @@ class App():
338 348  
339 349 def get_test_csv(self):
340 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 352 grades = sess.query(Test.student_id, Test.grade,
343 353 Test.starttime, Test.finishtime)\
344 354 .filter(Test.ref == self.testfactory['ref'])\
... ... @@ -360,21 +370,21 @@ class App():
360 370  
361 371 def get_student_grades_from_all_tests(self, uid):
362 372 '''get grades of student from all tests'''
363   - with self.db_session() as sess:
  373 + with self._db_session() as sess:
364 374 return sess.query(Test.title, Test.grade, Test.finishtime)\
365 375 .filter_by(student_id=uid)\
366 376 .order_by(Test.finishtime)
367 377  
368 378 def get_json_filename_of_test(self, test_id):
369 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 381 return sess.query(Test.filename)\
372 382 .filter_by(id=test_id)\
373 383 .scalar()
374 384  
375 385 def get_student_grades_from_test(self, uid, testid):
376 386 '''get grades of student for a given testid'''
377   - with self.db_session() as sess:
  387 + with self._db_session() as sess:
378 388 return sess.query(Test.grade, Test.finishtime, Test.id)\
379 389 .filter_by(student_id=uid)\
380 390 .filter_by(ref=testid)\
... ... @@ -399,7 +409,7 @@ class App():
399 409 # --- private methods ----------------------------------------------------
400 410 def _get_all_students(self):
401 411 '''get all students from database'''
402   - with self.db_session() as sess:
  412 + with self._db_session() as sess:
403 413 return sess.query(Student.id, Student.name, Student.password)\
404 414 .filter(Student.id != '0')\
405 415 .order_by(Student.id)
... ... @@ -433,13 +443,33 @@ class App():
433 443 '''allow all students to login'''
434 444 all_students = self._get_all_students()
435 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 448 def deny_all_students(self):
439 449 '''deny all students to login'''
440 450 logger.info('Denying all students...')
441 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 473 def _focus_student(self, uid):
444 474 '''set student in focus state'''
445 475 self.unfocus.discard(uid)
... ... @@ -462,7 +492,7 @@ class App():
462 492 '''change password on the database'''
463 493 if password:
464 494 password = await hash_password(password)
465   - with self.db_session() as sess:
  495 + with self._db_session() as sess:
466 496 student = sess.query(Student).filter_by(id=uid).one()
467 497 student.password = password
468 498 logger.info('"%s" password updated.', uid)
... ... @@ -470,7 +500,7 @@ class App():
470 500 def insert_new_student(self, uid, name):
471 501 '''insert new student into the database'''
472 502 try:
473   - with self.db_session() as sess:
  503 + with self._db_session() as sess:
474 504 sess.add(Student(id=uid, name=name, password=''))
475 505 except exc.SQLAlchemyError:
476 506 logger.error('Insert failed: student %s already exists?', uid)
... ...
perguntations/main.py
... ... @@ -32,10 +32,14 @@ def parse_cmdline_arguments():
32 32 'included with this software before running the server.')
33 33 parser.add_argument('testfile',
34 34 type=str,
  35 + # nargs='+', # TODO
35 36 help='tests in YAML format')
36 37 parser.add_argument('--allow-all',
37 38 action='store_true',
38 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 43 parser.add_argument('--debug',
40 44 action='store_true',
41 45 help='Enable debug messages')
... ... @@ -46,9 +50,12 @@ def parse_cmdline_arguments():
46 50 action='store_true',
47 51 help='Review mode: doesn\'t generate test')
48 52 parser.add_argument('--port',
49   - type=int, default=8443,
  53 + type=int,
  54 + default=8443,
50 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 59 help='Show version information and exit')
53 60 return parser.parse_args()
54 61  
... ... @@ -119,6 +126,7 @@ def main():
119 126 'testfile': args.testfile,
120 127 'debug': args.debug,
121 128 'allow_all': args.allow_all,
  129 + 'allow_list': args.allow_list,
122 130 'show_ref': args.show_ref,
123 131 'review': args.review,
124 132 }
... ...
perguntations/serve.py
... ... @@ -518,6 +518,7 @@ def run_webserver(app, ssl_opt, port, debug):
518 518 '''
519 519  
520 520 # --- create web application
  521 + logging.info('-----------------------------------------------------------')
521 522 logging.info('Starting WebApplication (tornado)')
522 523 try:
523 524 webapp = WebApplication(app, debug=debug)
... ...
perguntations/static/css/test.css
... ... @@ -8,6 +8,11 @@ body {
8 8 background: #bbb;
9 9 }
10 10  
  11 +/*code {
  12 + white-space: pre;
  13 +}
  14 +*/
  15 +
11 16 /* Hack to avoid name clash between pygments and mathjax */
12 17 .MathJax .mo,
13 18 .MathJax .mi {
... ...
perguntations/static/popper.js
... ... @@ -1 +0,0 @@
1   -../../node_modules/popper.js/dist/
2 0 \ No newline at end of file
perguntations/test.py
... ... @@ -48,7 +48,7 @@ class TestFactory(dict):
48 48 'duration': 0, # 0=infinite
49 49 'autosubmit': False,
50 50 'debug': False,
51   - 'show_ref': False
  51 + 'show_ref': False,
52 52 })
53 53 self.update(conf)
54 54  
... ... @@ -122,7 +122,6 @@ class TestFactory(dict):
122 122 if qmissing:
123 123 raise TestFactoryException(f'Could not find questions {qmissing}.')
124 124  
125   -
126 125 # ------------------------------------------------------------------------
127 126 def check_test_ref(self):
128 127 '''Test must have a `ref`'''
... ... @@ -223,7 +222,7 @@ class TestFactory(dict):
223 222 self.check_grade_scaling()
224 223  
225 224 # ------------------------------------------------------------------------
226   - async def generate(self): #, student):
  225 + async def generate(self):
227 226 '''
228 227 Given a dictionary with a student dict {'name':'john', 'number': 123}
229 228 returns instance of Test() for that particular student
... ... @@ -235,27 +234,29 @@ class TestFactory(dict):
235 234 nerr = 0 # count errors during questions generation
236 235  
237 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 261 # setup scale
261 262 total_points = sum(q['points'] for q in questions)
... ... @@ -320,13 +321,20 @@ class Test(dict):
320 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 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 340 async def correct(self):
... ... @@ -351,7 +359,7 @@ class Test(dict):
351 359 self['finish_time'] = datetime.now()
352 360 self['state'] = 'QUIT'
353 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 363 return self['grade']
356 364  
357 365 # ------------------------------------------------------------------------
... ...