Commit bb58d2f441be7cf88c79ba5b20ba20ad585cfa7a

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

- changes how database is updated after correction of a test

- fix get_questions_csv when there are questions with duplicate refs
- modified database model to include question number and comment in the
questions table. Also removed student_id so that it is now normalized.
1 1
2 # BUGS 2 # BUGS
3 3
4 -- perguntas repetidas (mesma ref) dao asneira, porque a referencia é usada como chave em varios sitios e as chaves nao podem ser dupplicadas.  
5 - da asneira pelo menos na funcao get_questions_csv. na base de dados tem de estar registado tb o numero da pergunta, caso contrario é impossível saber a qual corresponde. 4 +- esta a corrigir código JOBE mesmo que nao tenha respondido???
6 - show-ref nao esta a funcionar na correccao (pelo menos) 5 - show-ref nao esta a funcionar na correccao (pelo menos)
7 -- algumas submissões ficaram em duplicado na base de dados. a base de dados deveria ter como chave do teste um id que fosse único desse teste particular (não um auto counter, nem ref do teste)  
8 -- guardar nota final grade truncado em zero e sem ser truncado (quando é necessário fazer correcções à mão às perguntas, é necessário o valor não truncado)  
9 -- internal server error quando em --review, download csv detalhado. 6 +- algumas vezes a base de dados guarda o mesmo teste em duplicado. ver se dois submits dao origem a duas correcções.
  7 +talvez a base de dados devesse ter como chave do teste um id que fosse único desse teste particular (não um auto counter, nem ref do teste)
10 - JOBE correct async 8 - JOBE correct async
11 - em caso de timeout na submissão (e.g. JOBE ou script nao responde) a correcção não termina e o teste não é guardado. 9 - em caso de timeout na submissão (e.g. JOBE ou script nao responde) a correcção não termina e o teste não é guardado.
12 - QuestionCode falta reportar nos comments os vários erros que podem ocorrer (timeout, etc) 10 - QuestionCode falta reportar nos comments os vários erros que podem ocorrer (timeout, etc)
13 -- permitir remover alunos que estão online para poderem comecar de novo.  
14 - grade gives internal server error?? 11 - grade gives internal server error??
15 - reload do teste recomeça a contagem no inicio do tempo. 12 - reload do teste recomeça a contagem no inicio do tempo.
16 - 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. 13 - 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.
@@ -24,6 +21,8 @@ @@ -24,6 +21,8 @@
24 21
25 # TODO 22 # TODO
26 23
  24 +- permitir remover alunos que estão online para poderem comecar de novo.
  25 +- guardar nota final grade truncado em zero e sem ser truncado (quando é necessário fazer correcções à mão às perguntas, é necessário o valor não truncado)
27 - stress tests. use https://locust.io 26 - stress tests. use https://locust.io
28 - wait for admin to start test. (students can be allowed earlier) 27 - wait for admin to start test. (students can be allowed earlier)
29 - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente? 28 - impedir os eventos copy/paste. alunos usam isso para trazer codigo ja feito nos computadores. Obrigar a fazer reset? fazer um copy automaticamente?
@@ -72,6 +71,9 @@ ou usar push (websockets?) @@ -72,6 +71,9 @@ ou usar push (websockets?)
72 71
73 # FIXED 72 # FIXED
74 73
  74 +- internal server error quando em --review, download csv detalhado.
  75 +- perguntas repetidas (mesma ref) dao asneira, porque a referencia é usada como chave em varios sitios e as chaves nao podem ser dupplicadas.
  76 + da asneira pelo menos na funcao get_questions_csv. na base de dados tem de estar registado tb o numero da pergunta, caso contrario é impossível saber a qual corresponde.
75 - mostrar unfocus e window area em /admin 77 - mostrar unfocus e window area em /admin
76 - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) 78 - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?)
77 - botao de autorizar desliga-se, fazer debounce. 79 - botao de autorizar desliga-se, fazer debounce.
perguntations/app.py
@@ -258,61 +258,66 @@ class App(): @@ -258,61 +258,66 @@ class App():
258 fname = '--'.join(fields) + '.json' 258 fname = '--'.join(fields) + '.json'
259 fpath = path.join(test['answers_dir'], fname) 259 fpath = path.join(test['answers_dir'], fname)
260 with open(path.expanduser(fpath), 'w') as file: 260 with open(path.expanduser(fpath), 'w') as file:
261 - # default=str required for datetime objects  
262 json.dump(test, file, indent=2, default=str) 261 json.dump(test, file, indent=2, default=str)
  262 + # option default=str is required for datetime objects
  263 +
263 logger.info('"%s" saved JSON.', uid) 264 logger.info('"%s" saved JSON.', uid)
264 265
265 # --- insert test and questions into database 266 # --- insert test and questions into database
  267 + test_row = Test(
  268 + ref=test['ref'],
  269 + title=test['title'],
  270 + grade=test['grade'],
  271 + state=test['state'],
  272 + comment='',
  273 + starttime=str(test['start_time']),
  274 + finishtime=str(test['finish_time']),
  275 + filename=fpath,
  276 + student_id=uid)
  277 + test_row.questions = [Question(
  278 + number=n,
  279 + ref=q['ref'],
  280 + grade=q['grade'],
  281 + comment=q.get('comment', ''),
  282 + starttime=str(test['start_time']),
  283 + finishtime=str(test['finish_time']),
  284 + test_id=test['ref'])
  285 + for n, q in enumerate(test['questions'])
  286 + if 'grade' in q
  287 + ]
266 with self._db_session() as sess: 288 with self._db_session() as sess:
267 - sess.add(Test(  
268 - ref=test['ref'],  
269 - title=test['title'],  
270 - grade=test['grade'],  
271 - starttime=str(test['start_time']),  
272 - finishtime=str(test['finish_time']),  
273 - filename=fpath,  
274 - student_id=uid,  
275 - state=test['state'],  
276 - comment=''))  
277 - sess.add_all([Question(  
278 - ref=q['ref'],  
279 - grade=q['grade'],  
280 - starttime=str(test['start_time']),  
281 - finishtime=str(test['finish_time']),  
282 - student_id=uid,  
283 - test_id=test['ref'])  
284 - for q in test['questions'] if 'grade' in q]) 289 + sess.add(test_row)
285 290
286 logger.info('"%s" database updated.', uid) 291 logger.info('"%s" database updated.', uid)
287 return grade 292 return grade
288 293
289 # ------------------------------------------------------------------------ 294 # ------------------------------------------------------------------------
290 - def giveup_test(self, uid):  
291 - '''giveup test - not used??'''  
292 - test = self.online[uid]['test']  
293 - test.giveup()  
294 -  
295 - # save JSON with the test  
296 - fields = (test['student']['number'], test['ref'],  
297 - str(test['finish_time']))  
298 - fname = '--'.join(fields) + '.json'  
299 - fpath = path.join(test['answers_dir'], fname)  
300 - test.save_json(fpath)  
301 -  
302 - # insert test into database  
303 - with self._db_session() as sess:  
304 - sess.add(Test(ref=test['ref'],  
305 - title=test['title'],  
306 - grade=test['grade'],  
307 - starttime=str(test['start_time']),  
308 - finishtime=str(test['finish_time']),  
309 - filename=fpath,  
310 - student_id=test['student']['number'],  
311 - state=test['state'],  
312 - comment=''))  
313 -  
314 - logger.info('"%s" gave up.', uid)  
315 - return test 295 + # def giveup_test(self, uid):
  296 + # '''giveup test - not used??'''
  297 + # test = self.online[uid]['test']
  298 + # test.giveup()
  299 +
  300 + # # save JSON with the test
  301 + # fields = (test['student']['number'], test['ref'],
  302 + # str(test['finish_time']))
  303 + # fname = '--'.join(fields) + '.json'
  304 + # fpath = path.join(test['answers_dir'], fname)
  305 + # test.save_json(fpath)
  306 +
  307 + # # insert test into database
  308 + # with self._db_session() as sess:
  309 + # sess.add(Test(ref=test['ref'],
  310 + # title=test['title'],
  311 + # grade=test['grade'],
  312 + # starttime=str(test['start_time']),
  313 + # finishtime=str(test['finish_time']),
  314 + # filename=fpath,
  315 + # # student_id=test['student']['number'],
  316 + # state=test['state'],
  317 + # comment=''))
  318 +
  319 + # logger.info('"%s" gave up.', uid)
  320 + # return test
316 321
317 # ------------------------------------------------------------------------ 322 # ------------------------------------------------------------------------
318 def event_test(self, uid, cmd, value): 323 def event_test(self, uid, cmd, value):
@@ -334,55 +339,56 @@ class App(): @@ -334,55 +339,56 @@ class App():
334 339
335 def get_questions_csv(self): 340 def get_questions_csv(self):
336 '''generates a CSV with the grades of the test''' 341 '''generates a CSV with the grades of the test'''
337 - test_id = self.testfactory['ref']  
338 - 342 + test_ref = self.testfactory['ref']
339 with self._db_session() as sess: 343 with self._db_session() as sess:
340 - grades = sess.query(Question.student_id, Question.starttime,  
341 - Question.ref, Question.grade)\  
342 - .filter(Question.test_id == test_id)\  
343 - .order_by(Question.student_id)\  
344 - .all()  
345 -  
346 - # first row of the csv will include student_id, date/time, and refs for  
347 - # all questions declared in the test  
348 - cols = ['Aluno', 'Início'] + \  
349 - [r for question in self.testfactory['questions']  
350 - for r in question['ref']]  
351 -  
352 - # tests is a dict of dicts: FIXME what about duplicate qrefs???  
353 - # { (student_id, time): {qref: grade, ...} }  
354 - # the key is a tuple (student_id, time) because the same student can  
355 - # repeat the same test multiple times (if the professor allows).  
356 - tests = {}  
357 - for question in grades:  
358 - student, qref, qgrade = question[:2], *question[2:]  
359 - tests.setdefault(student, {})[qref] = qgrade  
360 -  
361 - rows = [{'Aluno': test[0], 'Início': test[1], **q}  
362 - for test, q in tests.items()] 344 + questions = sess.query(Test.id, Test.student_id, Test.starttime,
  345 + Question.number, Question.grade)\
  346 + .join(Question)\
  347 + .filter(Test.ref == test_ref)\
  348 + .all()
  349 +
  350 + qnums = set() # keeps track of all the questions in the test
  351 + tests = {} # {test_id: {student_id, starttime, 0: grade, ...}}
  352 + for question in questions:
  353 + test_id, student_id, starttime, num, grade = question
  354 + default_test_id = {'Aluno': student_id, 'Início': starttime}
  355 + tests.setdefault(test_id, default_test_id)[num] = grade
  356 + qnums.add(num)
  357 +
  358 + if not tests:
  359 + logger.warning('Empty CSV: there are no tests!')
  360 + return test_ref, ''
  361 +
  362 + cols = ['Aluno', 'Início'] + list(qnums)
363 363
364 csvstr = io.StringIO() 364 csvstr = io.StringIO()
365 writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, 365 writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None,
366 delimiter=';', quoting=csv.QUOTE_ALL) 366 delimiter=';', quoting=csv.QUOTE_ALL)
367 writer.writeheader() 367 writer.writeheader()
368 - writer.writerows(rows)  
369 - return test_id, csvstr.getvalue()  
370 - 368 + writer.writerows(tests.values())
  369 + return test_ref, csvstr.getvalue()
371 370
372 def get_test_csv(self): 371 def get_test_csv(self):
373 - '''generates a CSV with the grades of the test''' 372 + '''generates a CSV with the grades of the test currently running'''
  373 + test_ref = self.testfactory['ref']
374 with self._db_session() as sess: 374 with self._db_session() as sess:
375 - grades = sess.query(Test.student_id, Test.grade, 375 + tests = sess.query(Test.student_id,
  376 + Test.grade,
376 Test.starttime, Test.finishtime)\ 377 Test.starttime, Test.finishtime)\
377 - .filter(Test.ref == self.testfactory['ref'])\ 378 + .filter(Test.ref == test_ref)\
378 .order_by(Test.student_id)\ 379 .order_by(Test.student_id)\
379 .all() 380 .all()
380 381
  382 + if not tests:
  383 + logger.warning('Empty CSV: there are no tests!')
  384 + return test_ref, ''
  385 +
381 csvstr = io.StringIO() 386 csvstr = io.StringIO()
382 writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) 387 writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL)
383 writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) 388 writer.writerow(('Aluno', 'Nota', 'Início', 'Fim'))
384 - writer.writerows(grades)  
385 - return self.testfactory['ref'], csvstr.getvalue() 389 + writer.writerows(tests)
  390 +
  391 + return test_ref, csvstr.getvalue()
386 392
387 # ------------------------------------------------------------------------ 393 # ------------------------------------------------------------------------
388 def get_student_grades_from_all_tests(self, uid): 394 def get_student_grades_from_all_tests(self, uid):
perguntations/initdb.py
@@ -19,7 +19,7 @@ import sqlalchemy as sa @@ -19,7 +19,7 @@ import sqlalchemy as sa
19 from perguntations.models import Base, Student 19 from perguntations.models import Base, Student
20 20
21 21
22 -# =========================================================================== 22 +# ============================================================================
23 def parse_commandline_arguments(): 23 def parse_commandline_arguments():
24 '''Parse command line options''' 24 '''Parse command line options'''
25 parser = argparse.ArgumentParser( 25 parser = argparse.ArgumentParser(
@@ -68,7 +68,7 @@ def parse_commandline_arguments(): @@ -68,7 +68,7 @@ def parse_commandline_arguments():
68 return parser.parse_args() 68 return parser.parse_args()
69 69
70 70
71 -# =========================================================================== 71 +# ============================================================================
72 def get_students_from_csv(filename): 72 def get_students_from_csv(filename):
73 ''' 73 '''
74 SIIUE names have alien strings like "(TE)" and are sometimes capitalized 74 SIIUE names have alien strings like "(TE)" and are sometimes capitalized
@@ -97,7 +97,7 @@ def get_students_from_csv(filename): @@ -97,7 +97,7 @@ def get_students_from_csv(filename):
97 return students 97 return students
98 98
99 99
100 -# =========================================================================== 100 +# ============================================================================
101 def hashpw(student, password=None): 101 def hashpw(student, password=None):
102 '''replace password by hash for a single student''' 102 '''replace password by hash for a single student'''
103 print('.', end='', flush=True) 103 print('.', end='', flush=True)
@@ -108,7 +108,7 @@ def hashpw(student, password=None): @@ -108,7 +108,7 @@ def hashpw(student, password=None):
108 bcrypt.gensalt()) 108 bcrypt.gensalt())
109 109
110 110
111 -# =========================================================================== 111 +# ============================================================================
112 def insert_students_into_db(session, students): 112 def insert_students_into_db(session, students):
113 '''insert list of students into the database''' 113 '''insert list of students into the database'''
114 try: 114 try:
perguntations/models.py
@@ -25,9 +25,8 @@ class Student(Base): @@ -25,9 +25,8 @@ class Student(Base):
25 25
26 # --- 26 # ---
27 tests = relationship('Test', back_populates='student') 27 tests = relationship('Test', back_populates='student')
28 - questions = relationship('Question', back_populates='student')  
29 28
30 - def __repr__(self): 29 + def __str__(self):
31 return (f'Student:\n' 30 return (f'Student:\n'
32 f' id: "{self.id}"\n' 31 f' id: "{self.id}"\n'
33 f' name: "{self.name}"\n' 32 f' name: "{self.name}"\n'
@@ -53,12 +52,12 @@ class Test(Base): @@ -53,12 +52,12 @@ class Test(Base):
53 student = relationship('Student', back_populates='tests') 52 student = relationship('Student', back_populates='tests')
54 questions = relationship('Question', back_populates='test') 53 questions = relationship('Question', back_populates='test')
55 54
56 - def __repr__(self): 55 + def __str__(self):
57 return (f'Test:\n' 56 return (f'Test:\n'
58 - f' id: "{self.id}"\n' 57 + f' id: {self.id}\n'
59 f' ref: "{self.ref}"\n' 58 f' ref: "{self.ref}"\n'
60 f' title: "{self.title}"\n' 59 f' title: "{self.title}"\n'
61 - f' grade: "{self.grade}"\n' 60 + f' grade: {self.grade}\n'
62 f' state: "{self.state}"\n' 61 f' state: "{self.state}"\n'
63 f' comment: "{self.comment}"\n' 62 f' comment: "{self.comment}"\n'
64 f' starttime: "{self.starttime}"\n' 63 f' starttime: "{self.starttime}"\n'
@@ -72,23 +71,24 @@ class Question(Base): @@ -72,23 +71,24 @@ class Question(Base):
72 '''Question table''' 71 '''Question table'''
73 __tablename__ = 'questions' 72 __tablename__ = 'questions'
74 id = Column(Integer, primary_key=True) # auto_increment 73 id = Column(Integer, primary_key=True) # auto_increment
  74 + number = Column(Integer) # question number (ref may be not be unique)
75 ref = Column(String) 75 ref = Column(String)
76 grade = Column(Float) 76 grade = Column(Float)
  77 + comment = Column(String)
77 starttime = Column(String) 78 starttime = Column(String)
78 finishtime = Column(String) 79 finishtime = Column(String)
79 - student_id = Column(String, ForeignKey('students.id'))  
80 test_id = Column(String, ForeignKey('tests.id')) 80 test_id = Column(String, ForeignKey('tests.id'))
81 81
82 # --- 82 # ---
83 - student = relationship('Student', back_populates='questions')  
84 test = relationship('Test', back_populates='questions') 83 test = relationship('Test', back_populates='questions')
85 84
86 - def __repr__(self): 85 + def __str__(self):
87 return (f'Question:\n' 86 return (f'Question:\n'
88 - f' id: "{self.id}"\n' 87 + f' id: {self.id}\n'
  88 + f' number: {self.number}\n'
89 f' ref: "{self.ref}"\n' 89 f' ref: "{self.ref}"\n'
90 - f' grade: "{self.grade}"\n' 90 + f' grade: {self.grade}\n'
  91 + f' comment: "{self.comment}"\n'
91 f' starttime: "{self.starttime}"\n' 92 f' starttime: "{self.starttime}"\n'
92 f' finishtime: "{self.finishtime}"\n' 93 f' finishtime: "{self.finishtime}"\n'
93 - f' student_id: "{self.student_id}"\n' # FIXME normal form  
94 f' test_id: "{self.test_id}"\n') 94 f' test_id: "{self.test_id}"\n')