Commit 24244d74179f1e7a9b2619ea2b5a4cc1db5ba389

Authored by Miguel Barão
1 parent 772e7c3c
Exists in master and in 1 other branch dev

command line option --allow-list students.txt to pre-allow students listed

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/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,25 @@ 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 + logger.info('Generating tests for %d students...', len(self.allowed))
  118 + self._pregenerate_tests(len(self.allowed))
  119 + logger.info('Tests done.')
  120 +
118 121 # ------------------------------------------------------------------------
119 122 async def login(self, uid, try_pw):
120 123 '''login authentication'''
... ... @@ -123,7 +126,7 @@ class App():
123 126 return False
124 127  
125 128 # get name+password from db
126   - with self.db_session() as sess:
  129 + with self._db_session() as sess:
127 130 name, password = sess.query(Student.name, Student.password)\
128 131 .filter_by(id=uid)\
129 132 .one()
... ... @@ -183,31 +186,34 @@ class App():
183 186  
184 187 # ------------------------------------------------------------------------
185 188 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)
  189 + event_loop = asyncio.get_event_loop()
  190 + # for _ in range(num):
  191 + # test = event_loop.run_until_complete(self.testfactory.generate())
  192 + # self.pregenerated_tests.append(test)
  193 +
  194 + self.pregenerated_tests += [
  195 + event_loop.run_until_complete(self.testfactory.generate())
  196 + for _ in range(num)]
190 197  
191 198 # ------------------------------------------------------------------------
192 199 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)
  200 + '''generate a test for a given student. the student must be online'''
  201 +
  202 + student_id = self.online[uid]['student'] # {'name': ?, 'number': ?}
202 203  
203   - student_id = self.online[uid]['student'] # {number, name}
204   - test.start(student_id)
205   - self.online[uid]['test'] = test
  204 + try:
  205 + test = self.pregenerated_tests.pop()
  206 + except IndexError:
  207 + logger.info('"%s" generating new test...', uid)
  208 + test = await self.testfactory.generate()
206 209 logger.info('"%s" test is ready.', uid)
207   - return self.online[uid]['test']
  210 + else:
  211 + logger.info('"%s" using a pregenerated test.', uid)
208 212  
209   - # this implies an error in the program, code should be unreachable!
210   - logger.critical('"%s" is offline, can\'t generate test', uid)
  213 + test.start(student_id) # student signs the test
  214 + self.online[uid]['test'] = test # register test for this student
  215 +
  216 + return self.online[uid]['test']
211 217  
212 218 # ------------------------------------------------------------------------
213 219 async def correct_test(self, uid, ans):
... ... @@ -236,7 +242,7 @@ class App():
236 242 logger.info('"%s" saved JSON.', uid)
237 243  
238 244 # --- insert test and questions into database
239   - with self.db_session() as sess:
  245 + with self._db_session() as sess:
240 246 sess.add(Test(
241 247 ref=test['ref'],
242 248 title=test['title'],
... ... @@ -273,7 +279,7 @@ class App():
273 279 test.save_json(fpath)
274 280  
275 281 # insert test into database
276   - with self.db_session() as sess:
  282 + with self._db_session() as sess:
277 283 sess.add(Test(ref=test['ref'],
278 284 title=test['title'],
279 285 grade=test['grade'],
... ... @@ -309,7 +315,7 @@ class App():
309 315 '''generates a CSV with the grades of the test'''
310 316 test_id = self.testfactory['ref']
311 317  
312   - with self.db_session() as sess:
  318 + with self._db_session() as sess:
313 319 grades = sess.query(Question.student_id, Question.starttime,
314 320 Question.ref, Question.grade)\
315 321 .filter(Question.test_id == test_id)\
... ... @@ -338,7 +344,7 @@ class App():
338 344  
339 345 def get_test_csv(self):
340 346 '''generates a CSV with the grades of the test'''
341   - with self.db_session() as sess:
  347 + with self._db_session() as sess:
342 348 grades = sess.query(Test.student_id, Test.grade,
343 349 Test.starttime, Test.finishtime)\
344 350 .filter(Test.ref == self.testfactory['ref'])\
... ... @@ -360,21 +366,21 @@ class App():
360 366  
361 367 def get_student_grades_from_all_tests(self, uid):
362 368 '''get grades of student from all tests'''
363   - with self.db_session() as sess:
  369 + with self._db_session() as sess:
364 370 return sess.query(Test.title, Test.grade, Test.finishtime)\
365 371 .filter_by(student_id=uid)\
366 372 .order_by(Test.finishtime)
367 373  
368 374 def get_json_filename_of_test(self, test_id):
369 375 '''get JSON filename from database given the test_id'''
370   - with self.db_session() as sess:
  376 + with self._db_session() as sess:
371 377 return sess.query(Test.filename)\
372 378 .filter_by(id=test_id)\
373 379 .scalar()
374 380  
375 381 def get_student_grades_from_test(self, uid, testid):
376 382 '''get grades of student for a given testid'''
377   - with self.db_session() as sess:
  383 + with self._db_session() as sess:
378 384 return sess.query(Test.grade, Test.finishtime, Test.id)\
379 385 .filter_by(student_id=uid)\
380 386 .filter_by(ref=testid)\
... ... @@ -399,7 +405,7 @@ class App():
399 405 # --- private methods ----------------------------------------------------
400 406 def _get_all_students(self):
401 407 '''get all students from database'''
402   - with self.db_session() as sess:
  408 + with self._db_session() as sess:
403 409 return sess.query(Student.id, Student.name, Student.password)\
404 410 .filter(Student.id != '0')\
405 411 .order_by(Student.id)
... ... @@ -433,13 +439,33 @@ class App():
433 439 '''allow all students to login'''
434 440 all_students = self._get_all_students()
435 441 self.allowed.update(s[0] for s in all_students)
436   - logger.info('Allowed all students.')
  442 + logger.info('Allowed all %d students.', len(self.allowed))
437 443  
438 444 def deny_all_students(self):
439 445 '''deny all students to login'''
440 446 logger.info('Denying all students...')
441 447 self.allowed.clear()
442 448  
  449 + def allow_list(self, filename):
  450 + '''allow students listed in file (one number per line)'''
  451 + try:
  452 + with open(filename, 'r') as file:
  453 + allowed_in_file = {s.strip() for s in file} - {''}
  454 + except Exception as exc:
  455 + error_msg = f'Cannot read file {filename}'
  456 + logger.critical(error_msg)
  457 + raise AppException(error_msg) from exc
  458 +
  459 + enrolled = set(s[0] for s in self._get_all_students()) # in database
  460 + self.allowed.update(allowed_in_file & enrolled)
  461 + logger.info('Allowed %d students provided in %s.', len(self.allowed),
  462 + filename)
  463 +
  464 + not_enrolled = allowed_in_file - enrolled
  465 + if not_enrolled:
  466 + logger.warning(' but found students not in the database: %s',
  467 + ', '.join(not_enrolled))
  468 +
443 469 def _focus_student(self, uid):
444 470 '''set student in focus state'''
445 471 self.unfocus.discard(uid)
... ... @@ -462,7 +488,7 @@ class App():
462 488 '''change password on the database'''
463 489 if password:
464 490 password = await hash_password(password)
465   - with self.db_session() as sess:
  491 + with self._db_session() as sess:
466 492 student = sess.query(Student).filter_by(id=uid).one()
467 493 student.password = password
468 494 logger.info('"%s" password updated.', uid)
... ... @@ -470,7 +496,7 @@ class App():
470 496 def insert_new_student(self, uid, name):
471 497 '''insert new student into the database'''
472 498 try:
473   - with self.db_session() as sess:
  499 + with self._db_session() as sess:
474 500 sess.add(Student(id=uid, name=name, password=''))
475 501 except exc.SQLAlchemyError:
476 502 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/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,8 @@ 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 + # 'allow_students': None,
52 53 })
53 54 self.update(conf)
54 55  
... ... @@ -123,6 +124,7 @@ class TestFactory(dict):
123 124 raise TestFactoryException(f'Could not find questions {qmissing}.')
124 125  
125 126  
  127 +
126 128 # ------------------------------------------------------------------------
127 129 def check_test_ref(self):
128 130 '''Test must have a `ref`'''
... ... @@ -223,7 +225,7 @@ class TestFactory(dict):
223 225 self.check_grade_scaling()
224 226  
225 227 # ------------------------------------------------------------------------
226   - async def generate(self): #, student):
  228 + async def generate(self):
227 229 '''
228 230 Given a dictionary with a student dict {'name':'john', 'number': 123}
229 231 returns instance of Test() for that particular student
... ... @@ -320,13 +322,20 @@ class Test(dict):
320 322 question['answer'] = None
321 323  
322 324 # ------------------------------------------------------------------------
323   - def update_answers(self, ans):
  325 + def update_answer(self, ref, ans):
  326 + '''updates one answer in the test'''
  327 + self['questions'][ref].set_answer(ans)
  328 +
  329 + # ------------------------------------------------------------------------
  330 + def update_answers(self, answers_dict):
324 331 '''
325 332 Given a dictionary ans={'ref': 'some answer'} updates the answers of
326   - the test. Only affects the questions referred in the dictionary.
  333 + multiple questions in the test.
  334 + Only affects the questions referred in the dictionary.
327 335 '''
328   - for ref, answer in ans.items():
329   - self['questions'][ref]['answer'] = answer
  336 + for ref, ans in answers_dict.items():
  337 + self['questions'][ref].set_answer(ans)
  338 + # self['questions'][ref]['answer'] = ans
330 339  
331 340 # ------------------------------------------------------------------------
332 341 async def correct(self):
... ... @@ -351,7 +360,7 @@ class Test(dict):
351 360 self['finish_time'] = datetime.now()
352 361 self['state'] = 'QUIT'
353 362 self['grade'] = 0.0
354   - # logger.info('Student %s: gave up.', self["student"]["number"])
  363 + logger.info('Student %s: gave up.', self["student"]["number"])
355 364 return self['grade']
356 365  
357 366 # ------------------------------------------------------------------------
... ...