Commit 24244d74179f1e7a9b2619ea2b5a4cc1db5ba389
1 parent
772e7c3c
Exists in
master
and in
1 other branch
command line option --allow-list students.txt to pre-allow students listed
Showing
6 changed files
with
98 additions
and
54 deletions
Show diff stats
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
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 | # ------------------------------------------------------------------------ | ... | ... |