Commit bb58d2f441be7cf88c79ba5b20ba20ad585cfa7a
1 parent
aab6a036
Exists in
master
and in
1 other branch
- 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.
Showing
4 changed files
with
107 additions
and
99 deletions
Show diff stats
BUGS.md
1 | 1 | |
2 | 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 | 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 | 8 | - JOBE correct async |
11 | 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 | 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 | 11 | - grade gives internal server error?? |
15 | 12 | - reload do teste recomeça a contagem no inicio do tempo. |
16 | 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 | 21 | |
25 | 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 | 26 | - stress tests. use https://locust.io |
28 | 27 | - wait for admin to start test. (students can be allowed earlier) |
29 | 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 | 71 | |
73 | 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 | 77 | - mostrar unfocus e window area em /admin |
76 | 78 | - CRITICAL se answer for `i<n` a revisão de provas mostra apenas i (interpreta `<` como tag?) |
77 | 79 | - botao de autorizar desliga-se, fazer debounce. | ... | ... |
perguntations/app.py
... | ... | @@ -258,61 +258,66 @@ class App(): |
258 | 258 | fname = '--'.join(fields) + '.json' |
259 | 259 | fpath = path.join(test['answers_dir'], fname) |
260 | 260 | with open(path.expanduser(fpath), 'w') as file: |
261 | - # default=str required for datetime objects | |
262 | 261 | json.dump(test, file, indent=2, default=str) |
262 | + # option default=str is required for datetime objects | |
263 | + | |
263 | 264 | logger.info('"%s" saved JSON.', uid) |
264 | 265 | |
265 | 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 | 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 | 291 | logger.info('"%s" database updated.', uid) |
287 | 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 | 323 | def event_test(self, uid, cmd, value): |
... | ... | @@ -334,55 +339,56 @@ class App(): |
334 | 339 | |
335 | 340 | def get_questions_csv(self): |
336 | 341 | '''generates a CSV with the grades of the test''' |
337 | - test_id = self.testfactory['ref'] | |
338 | - | |
342 | + test_ref = self.testfactory['ref'] | |
339 | 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 | 364 | csvstr = io.StringIO() |
365 | 365 | writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, |
366 | 366 | delimiter=';', quoting=csv.QUOTE_ALL) |
367 | 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 | 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 | 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 | 377 | Test.starttime, Test.finishtime)\ |
377 | - .filter(Test.ref == self.testfactory['ref'])\ | |
378 | + .filter(Test.ref == test_ref)\ | |
378 | 379 | .order_by(Test.student_id)\ |
379 | 380 | .all() |
380 | 381 | |
382 | + if not tests: | |
383 | + logger.warning('Empty CSV: there are no tests!') | |
384 | + return test_ref, '' | |
385 | + | |
381 | 386 | csvstr = io.StringIO() |
382 | 387 | writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) |
383 | 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 | 394 | def get_student_grades_from_all_tests(self, uid): | ... | ... |
perguntations/initdb.py
... | ... | @@ -19,7 +19,7 @@ import sqlalchemy as sa |
19 | 19 | from perguntations.models import Base, Student |
20 | 20 | |
21 | 21 | |
22 | -# =========================================================================== | |
22 | +# ============================================================================ | |
23 | 23 | def parse_commandline_arguments(): |
24 | 24 | '''Parse command line options''' |
25 | 25 | parser = argparse.ArgumentParser( |
... | ... | @@ -68,7 +68,7 @@ def parse_commandline_arguments(): |
68 | 68 | return parser.parse_args() |
69 | 69 | |
70 | 70 | |
71 | -# =========================================================================== | |
71 | +# ============================================================================ | |
72 | 72 | def get_students_from_csv(filename): |
73 | 73 | ''' |
74 | 74 | SIIUE names have alien strings like "(TE)" and are sometimes capitalized |
... | ... | @@ -97,7 +97,7 @@ def get_students_from_csv(filename): |
97 | 97 | return students |
98 | 98 | |
99 | 99 | |
100 | -# =========================================================================== | |
100 | +# ============================================================================ | |
101 | 101 | def hashpw(student, password=None): |
102 | 102 | '''replace password by hash for a single student''' |
103 | 103 | print('.', end='', flush=True) |
... | ... | @@ -108,7 +108,7 @@ def hashpw(student, password=None): |
108 | 108 | bcrypt.gensalt()) |
109 | 109 | |
110 | 110 | |
111 | -# =========================================================================== | |
111 | +# ============================================================================ | |
112 | 112 | def insert_students_into_db(session, students): |
113 | 113 | '''insert list of students into the database''' |
114 | 114 | try: | ... | ... |
perguntations/models.py
... | ... | @@ -25,9 +25,8 @@ class Student(Base): |
25 | 25 | |
26 | 26 | # --- |
27 | 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 | 30 | return (f'Student:\n' |
32 | 31 | f' id: "{self.id}"\n' |
33 | 32 | f' name: "{self.name}"\n' |
... | ... | @@ -53,12 +52,12 @@ class Test(Base): |
53 | 52 | student = relationship('Student', back_populates='tests') |
54 | 53 | questions = relationship('Question', back_populates='test') |
55 | 54 | |
56 | - def __repr__(self): | |
55 | + def __str__(self): | |
57 | 56 | return (f'Test:\n' |
58 | - f' id: "{self.id}"\n' | |
57 | + f' id: {self.id}\n' | |
59 | 58 | f' ref: "{self.ref}"\n' |
60 | 59 | f' title: "{self.title}"\n' |
61 | - f' grade: "{self.grade}"\n' | |
60 | + f' grade: {self.grade}\n' | |
62 | 61 | f' state: "{self.state}"\n' |
63 | 62 | f' comment: "{self.comment}"\n' |
64 | 63 | f' starttime: "{self.starttime}"\n' |
... | ... | @@ -72,23 +71,24 @@ class Question(Base): |
72 | 71 | '''Question table''' |
73 | 72 | __tablename__ = 'questions' |
74 | 73 | id = Column(Integer, primary_key=True) # auto_increment |
74 | + number = Column(Integer) # question number (ref may be not be unique) | |
75 | 75 | ref = Column(String) |
76 | 76 | grade = Column(Float) |
77 | + comment = Column(String) | |
77 | 78 | starttime = Column(String) |
78 | 79 | finishtime = Column(String) |
79 | - student_id = Column(String, ForeignKey('students.id')) | |
80 | 80 | test_id = Column(String, ForeignKey('tests.id')) |
81 | 81 | |
82 | 82 | # --- |
83 | - student = relationship('Student', back_populates='questions') | |
84 | 83 | test = relationship('Test', back_populates='questions') |
85 | 84 | |
86 | - def __repr__(self): | |
85 | + def __str__(self): | |
87 | 86 | return (f'Question:\n' |
88 | - f' id: "{self.id}"\n' | |
87 | + f' id: {self.id}\n' | |
88 | + f' number: {self.number}\n' | |
89 | 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 | 92 | f' starttime: "{self.starttime}"\n' |
92 | 93 | f' finishtime: "{self.finishtime}"\n' |
93 | - f' student_id: "{self.student_id}"\n' # FIXME normal form | |
94 | 94 | f' test_id: "{self.test_id}"\n') | ... | ... |