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

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/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,25 @@ class App(): @@ -98,23 +99,25 @@ 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 + 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 async def login(self, uid, try_pw): 122 async def login(self, uid, try_pw):
120 '''login authentication''' 123 '''login authentication'''
@@ -123,7 +126,7 @@ class App(): @@ -123,7 +126,7 @@ class App():
123 return False 126 return False
124 127
125 # get name+password from db 128 # get name+password from db
126 - with self.db_session() as sess: 129 + with self._db_session() as sess:
127 name, password = sess.query(Student.name, Student.password)\ 130 name, password = sess.query(Student.name, Student.password)\
128 .filter_by(id=uid)\ 131 .filter_by(id=uid)\
129 .one() 132 .one()
@@ -183,31 +186,34 @@ class App(): @@ -183,31 +186,34 @@ class App():
183 186
184 # ------------------------------------------------------------------------ 187 # ------------------------------------------------------------------------
185 def _pregenerate_tests(self, num): 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 async def generate_test(self, uid): 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 logger.info('"%s" test is ready.', uid) 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 async def correct_test(self, uid, ans): 219 async def correct_test(self, uid, ans):
@@ -236,7 +242,7 @@ class App(): @@ -236,7 +242,7 @@ class App():
236 logger.info('"%s" saved JSON.', uid) 242 logger.info('"%s" saved JSON.', uid)
237 243
238 # --- insert test and questions into database 244 # --- insert test and questions into database
239 - with self.db_session() as sess: 245 + with self._db_session() as sess:
240 sess.add(Test( 246 sess.add(Test(
241 ref=test['ref'], 247 ref=test['ref'],
242 title=test['title'], 248 title=test['title'],
@@ -273,7 +279,7 @@ class App(): @@ -273,7 +279,7 @@ class App():
273 test.save_json(fpath) 279 test.save_json(fpath)
274 280
275 # insert test into database 281 # insert test into database
276 - with self.db_session() as sess: 282 + with self._db_session() as sess:
277 sess.add(Test(ref=test['ref'], 283 sess.add(Test(ref=test['ref'],
278 title=test['title'], 284 title=test['title'],
279 grade=test['grade'], 285 grade=test['grade'],
@@ -309,7 +315,7 @@ class App(): @@ -309,7 +315,7 @@ class App():
309 '''generates a CSV with the grades of the test''' 315 '''generates a CSV with the grades of the test'''
310 test_id = self.testfactory['ref'] 316 test_id = self.testfactory['ref']
311 317
312 - with self.db_session() as sess: 318 + with self._db_session() as sess:
313 grades = sess.query(Question.student_id, Question.starttime, 319 grades = sess.query(Question.student_id, Question.starttime,
314 Question.ref, Question.grade)\ 320 Question.ref, Question.grade)\
315 .filter(Question.test_id == test_id)\ 321 .filter(Question.test_id == test_id)\
@@ -338,7 +344,7 @@ class App(): @@ -338,7 +344,7 @@ class App():
338 344
339 def get_test_csv(self): 345 def get_test_csv(self):
340 '''generates a CSV with the grades of the test''' 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 grades = sess.query(Test.student_id, Test.grade, 348 grades = sess.query(Test.student_id, Test.grade,
343 Test.starttime, Test.finishtime)\ 349 Test.starttime, Test.finishtime)\
344 .filter(Test.ref == self.testfactory['ref'])\ 350 .filter(Test.ref == self.testfactory['ref'])\
@@ -360,21 +366,21 @@ class App(): @@ -360,21 +366,21 @@ class App():
360 366
361 def get_student_grades_from_all_tests(self, uid): 367 def get_student_grades_from_all_tests(self, uid):
362 '''get grades of student from all tests''' 368 '''get grades of student from all tests'''
363 - with self.db_session() as sess: 369 + with self._db_session() as sess:
364 return sess.query(Test.title, Test.grade, Test.finishtime)\ 370 return sess.query(Test.title, Test.grade, Test.finishtime)\
365 .filter_by(student_id=uid)\ 371 .filter_by(student_id=uid)\
366 .order_by(Test.finishtime) 372 .order_by(Test.finishtime)
367 373
368 def get_json_filename_of_test(self, test_id): 374 def get_json_filename_of_test(self, test_id):
369 '''get JSON filename from database given the test_id''' 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 return sess.query(Test.filename)\ 377 return sess.query(Test.filename)\
372 .filter_by(id=test_id)\ 378 .filter_by(id=test_id)\
373 .scalar() 379 .scalar()
374 380
375 def get_student_grades_from_test(self, uid, testid): 381 def get_student_grades_from_test(self, uid, testid):
376 '''get grades of student for a given testid''' 382 '''get grades of student for a given testid'''
377 - with self.db_session() as sess: 383 + with self._db_session() as sess:
378 return sess.query(Test.grade, Test.finishtime, Test.id)\ 384 return sess.query(Test.grade, Test.finishtime, Test.id)\
379 .filter_by(student_id=uid)\ 385 .filter_by(student_id=uid)\
380 .filter_by(ref=testid)\ 386 .filter_by(ref=testid)\
@@ -399,7 +405,7 @@ class App(): @@ -399,7 +405,7 @@ class App():
399 # --- private methods ---------------------------------------------------- 405 # --- private methods ----------------------------------------------------
400 def _get_all_students(self): 406 def _get_all_students(self):
401 '''get all students from database''' 407 '''get all students from database'''
402 - with self.db_session() as sess: 408 + with self._db_session() as sess:
403 return sess.query(Student.id, Student.name, Student.password)\ 409 return sess.query(Student.id, Student.name, Student.password)\
404 .filter(Student.id != '0')\ 410 .filter(Student.id != '0')\
405 .order_by(Student.id) 411 .order_by(Student.id)
@@ -433,13 +439,33 @@ class App(): @@ -433,13 +439,33 @@ class App():
433 '''allow all students to login''' 439 '''allow all students to login'''
434 all_students = self._get_all_students() 440 all_students = self._get_all_students()
435 self.allowed.update(s[0] for s in all_students) 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 def deny_all_students(self): 444 def deny_all_students(self):
439 '''deny all students to login''' 445 '''deny all students to login'''
440 logger.info('Denying all students...') 446 logger.info('Denying all students...')
441 self.allowed.clear() 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 def _focus_student(self, uid): 469 def _focus_student(self, uid):
444 '''set student in focus state''' 470 '''set student in focus state'''
445 self.unfocus.discard(uid) 471 self.unfocus.discard(uid)
@@ -462,7 +488,7 @@ class App(): @@ -462,7 +488,7 @@ class App():
462 '''change password on the database''' 488 '''change password on the database'''
463 if password: 489 if password:
464 password = await hash_password(password) 490 password = await hash_password(password)
465 - with self.db_session() as sess: 491 + with self._db_session() as sess:
466 student = sess.query(Student).filter_by(id=uid).one() 492 student = sess.query(Student).filter_by(id=uid).one()
467 student.password = password 493 student.password = password
468 logger.info('"%s" password updated.', uid) 494 logger.info('"%s" password updated.', uid)
@@ -470,7 +496,7 @@ class App(): @@ -470,7 +496,7 @@ class App():
470 def insert_new_student(self, uid, name): 496 def insert_new_student(self, uid, name):
471 '''insert new student into the database''' 497 '''insert new student into the database'''
472 try: 498 try:
473 - with self.db_session() as sess: 499 + with self._db_session() as sess:
474 sess.add(Student(id=uid, name=name, password='')) 500 sess.add(Student(id=uid, name=name, password=''))
475 except exc.SQLAlchemyError: 501 except exc.SQLAlchemyError:
476 logger.error('Insert failed: student %s already exists?', uid) 502 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/popper.js
@@ -1 +0,0 @@ @@ -1 +0,0 @@
1 -../../node_modules/popper.js/dist/  
2 \ No newline at end of file 0 \ No newline at end of file
perguntations/test.py
@@ -48,7 +48,8 @@ class TestFactory(dict): @@ -48,7 +48,8 @@ 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 + # 'allow_students': None,
52 }) 53 })
53 self.update(conf) 54 self.update(conf)
54 55
@@ -123,6 +124,7 @@ class TestFactory(dict): @@ -123,6 +124,7 @@ class TestFactory(dict):
123 raise TestFactoryException(f'Could not find questions {qmissing}.') 124 raise TestFactoryException(f'Could not find questions {qmissing}.')
124 125
125 126
  127 +
126 # ------------------------------------------------------------------------ 128 # ------------------------------------------------------------------------
127 def check_test_ref(self): 129 def check_test_ref(self):
128 '''Test must have a `ref`''' 130 '''Test must have a `ref`'''
@@ -223,7 +225,7 @@ class TestFactory(dict): @@ -223,7 +225,7 @@ class TestFactory(dict):
223 self.check_grade_scaling() 225 self.check_grade_scaling()
224 226
225 # ------------------------------------------------------------------------ 227 # ------------------------------------------------------------------------
226 - async def generate(self): #, student): 228 + async def generate(self):
227 ''' 229 '''
228 Given a dictionary with a student dict {'name':'john', 'number': 123} 230 Given a dictionary with a student dict {'name':'john', 'number': 123}
229 returns instance of Test() for that particular student 231 returns instance of Test() for that particular student
@@ -320,13 +322,20 @@ class Test(dict): @@ -320,13 +322,20 @@ class Test(dict):
320 question['answer'] = None 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 Given a dictionary ans={'ref': 'some answer'} updates the answers of 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 async def correct(self): 341 async def correct(self):
@@ -351,7 +360,7 @@ class Test(dict): @@ -351,7 +360,7 @@ class Test(dict):
351 self['finish_time'] = datetime.now() 360 self['finish_time'] = datetime.now()
352 self['state'] = 'QUIT' 361 self['state'] = 'QUIT'
353 self['grade'] = 0.0 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 return self['grade'] 364 return self['grade']
356 365
357 # ------------------------------------------------------------------------ 366 # ------------------------------------------------------------------------