Commit 00fef4421ea630ec89d7bc2b6b5d730c97b75735

Authored by Miguel Barão
2 parents badbefe5 bb42f4ab
Exists in dev

Merge branch 'dev' of https://git.xdi.uevora.pt/mjsb/perguntations into dev

perguntations/__init__.py
... ... @@ -32,7 +32,7 @@ proof of submission and for review.
32 32 '''
33 33  
34 34 APP_NAME = 'perguntations'
35   -APP_VERSION = '2024.06.dev1'
  35 +APP_VERSION = '2024.07.dev1'
36 36 APP_DESCRIPTION = str(__doc__)
37 37  
38 38 __author__ = 'Miguel Barão'
... ...
perguntations/app.py
1   -'''
  1 +"""
2 2 File: perguntations/app.py
3 3 Description: Main application logic.
4   -'''
  4 +"""
5 5  
6 6  
7 7 # python standard libraries
... ... @@ -33,73 +33,73 @@ logger = logging.getLogger(__name__)
33 33 # ============================================================================
34 34 ph = argon2.PasswordHasher()
35 35  
36   -async def check_password(password: str, hashed: bytes) -> bool:
37   - '''check password in executor'''
  36 +async def check_password(password: str, hashed: str) -> bool:
  37 + """check password in executor"""
38 38 loop = asyncio.get_running_loop()
39 39 try:
40   - return await loop.run_in_executor(None, ph.verify, hashed, password.encode('utf-8'))
  40 + return await loop.run_in_executor(None, ph.verify, hashed, password)
41 41 except argon2.exceptions.VerifyMismatchError:
42 42 return False
43 43 except Exception:
44 44 logger.error('Unkown error while verifying password')
45 45 return False
46 46  
47   -async def hash_password(password: str) -> bytes:
48   - '''get hash for password'''
  47 +async def hash_password(password: str) -> str:
  48 + """get hash for password"""
49 49 loop = asyncio.get_running_loop()
50   - hashed = await loop.run_in_executor(None, ph.hash, password)
51   - return hashed.encode('utf-8')
  50 + return await loop.run_in_executor(None, ph.hash, password)
  51 +
52 52  
53 53 # ============================================================================
54 54 class AppException(Exception):
55   - '''Exception raised in this module'''
  55 + """Exception raised in this module"""
  56 +
56 57  
57 58 # ============================================================================
58 59 # main application
59 60 # ============================================================================
60   -class App():
61   - '''
  61 +class App:
  62 + """
62 63 Main application
63   - '''
  64 + """
64 65  
65 66 # ------------------------------------------------------------------------
66 67 def __init__(self, config):
67   - self.debug = config['debug']
68   - self._make_test_factory(config['testfile'])
  68 + self.debug = config["debug"]
  69 + self._make_test_factory(config["testfile"])
69 70 self._db_setup() # setup engine and load all students
70 71  
71   - # FIXME get_event_loop will be deprecated in python3.10
  72 + # FIXME: get_event_loop will be deprecated in python3.
72 73 asyncio.get_event_loop().run_until_complete(self._assign_tests())
73 74  
74 75 # command line options: --allow-all, --allow-list filename
75   - if config['allow_all']:
  76 + if config["allow_all"]:
76 77 self.allow_all_students()
77   - elif config['allow_list'] is not None:
78   - self.allow_from_list(config['allow_list'])
  78 + elif config["allow_list"] is not None:
  79 + self.allow_from_list(config["allow_list"])
79 80 else:
80   - logger.info('Students not allowed to login')
  81 + logger.info("Students not allowed to login")
81 82  
82   - if config['correct']:
  83 + if config["correct"]:
83 84 self._correct_tests()
84 85  
85 86 # ------------------------------------------------------------------------
86 87 def _db_setup(self) -> None:
87   - '''
  88 + """
88 89 Create database engine and checks for admin and students
89   - '''
90   - dbfile = os.path.expanduser(self._testfactory['database'])
  90 + """
  91 + dbfile = os.path.expanduser(self._testfactory["database"])
91 92 logger.debug('Checking database "%s"...', dbfile)
92 93 if not os.path.exists(dbfile):
93 94 raise AppException('No database, use "initdb" to create')
94 95  
95 96 # connect to database and check for admin & registered students
96   - self._engine = create_engine(f'sqlite:///{dbfile}', future=True)
  97 + self._engine = create_engine(f"sqlite:///{dbfile}", future=True)
97 98 try:
98 99 with Session(self._engine, future=True) as session:
99   - query = select(Student.id, Student.name)\
100   - .where(Student.id != '0')
  100 + query = select(Student.id, Student.name).where(Student.id != "0")
101 101 dbstudents = session.execute(query).all()
102   - session.execute(select(Student).where(Student.id == '0')).one()
  102 + session.execute(select(Student).where(Student.id == "0")).one()
103 103 except NoResultFound:
104 104 msg = 'Database has no administrator (user "0")'
105 105 logger.error(msg)
... ... @@ -108,158 +108,162 @@ class App():
108 108 msg = f'Database "{dbfile}" unusable'
109 109 logger.error(msg)
110 110 raise AppException(msg) from None
111   - logger.info('Database has %d students', len(dbstudents))
  111 + logger.info("Database has %d students", len(dbstudents))
112 112  
113   - self._students = {uid: {
114   - 'name': name,
115   - 'state': 'offline',
116   - 'test': None,
117   - } for uid, name in dbstudents}
  113 + self._students = {
  114 + uid: {
  115 + "name": name,
  116 + "state": "offline",
  117 + "test": None,
  118 + }
  119 + for uid, name in dbstudents
  120 + }
118 121  
119 122 # ------------------------------------------------------------------------
120 123 async def _assign_tests(self) -> None:
121   - '''Generate tests for all students that don't yet have a test'''
122   - logger.info('Generating tests...')
  124 + """Generate tests for all students that don't yet have a test"""
  125 + logger.info("Generating tests...")
123 126 for student in self._students.values():
124   - if student.get('test', None) is None:
125   - student['test'] = await self._testfactory.generate()
126   - logger.info('Tests assigned to all students')
  127 + if student.get("test", None) is None:
  128 + student["test"] = await self._testfactory.generate()
  129 + logger.info("Tests assigned to all students")
127 130  
128 131 # ------------------------------------------------------------------------
129 132 async def login(self, uid: str, password: str, headers: dict) -> Optional[str]:
130   - '''
  133 + """
131 134 Login authentication
132 135 If successful returns None, else returns an error message
133   - '''
  136 + """
134 137 try:
135 138 with Session(self._engine, future=True) as session:
136 139 query = select(Student.password).where(Student.id == uid)
137 140 hashed = session.execute(query).scalar_one()
138 141 except NoResultFound:
139 142 logger.warning('"%s" does not exist', uid)
140   - return 'nonexistent'
  143 + return "nonexistent"
141 144  
142   - if uid != '0' and self._students[uid]['state'] != 'allowed':
  145 + if uid != "0" and self._students[uid]["state"] != "allowed":
143 146 logger.warning('"%s" login not allowed', uid)
144   - return 'not_allowed'
  147 + return "not_allowed"
145 148  
146   - if hashed == '': # set password on first login
  149 + if hashed == "": # set password on first login
147 150 await self.set_password(uid, password)
148 151 elif not await check_password(password, hashed):
149 152 logger.info('"%s" wrong password', uid)
150   - return 'wrong_password'
  153 + return "wrong_password"
151 154  
152 155 # success
153   - if uid == '0':
154   - logger.info('Admin login from %s', headers['remote_ip'])
  156 + if uid == "0":
  157 + logger.info("Admin login from %s", headers["remote_ip"])
155 158 else:
156 159 student = self._students[uid]
157   - student['test'].start(uid)
158   - student['state'] = 'online'
159   - student['headers'] = headers
160   - student['unfocus'] = False
161   - student['area'] = 0.0
162   - logger.info('"%s" login from %s', uid, headers['remote_ip'])
  160 + student["test"].start(uid)
  161 + student["state"] = "online"
  162 + student["headers"] = headers
  163 + student["unfocus"] = False
  164 + student["area"] = 0.0
  165 + logger.info('"%s" login from %s', uid, headers["remote_ip"])
163 166 return None
164 167  
165 168 # ------------------------------------------------------------------------
166 169 async def set_password(self, uid: str, password: str) -> None:
167   - '''change password in the database'''
  170 + """change password in the database"""
168 171 with Session(self._engine, future=True) as session:
169 172 query = select(Student).where(Student.id == uid)
170 173 student = session.execute(query).scalar_one()
171   - student.password = await hash_password(password) if password else ''
  174 + student.password = await hash_password(password) if password else ""
172 175 session.commit()
173 176 logger.info('"%s" password updated', uid)
174 177  
175 178 # ------------------------------------------------------------------------
176 179 def logout(self, uid: str) -> None:
177   - '''student logout'''
  180 + """student logout"""
178 181 student = self._students.get(uid, None)
179 182 if student is not None:
180 183 # student['test'] = None
181   - student['state'] = 'offline'
182   - student.pop('headers', None)
183   - student.pop('unfocus', None)
184   - student.pop('area', None)
  184 + student["state"] = "offline"
  185 + student.pop("headers", None)
  186 + student.pop("unfocus", None)
  187 + student.pop("area", None)
185 188 logger.info('"%s" logged out', uid)
186 189  
187 190 # ------------------------------------------------------------------------
188 191 def _make_test_factory(self, filename: str) -> None:
189   - '''
  192 + """
190 193 Setup a factory for the test
191   - '''
  194 + """
192 195  
193 196 # load configuration from yaml file
194 197 try:
195 198 testconf = load_yaml(filename)
196   - testconf['testfile'] = filename
  199 + testconf["testfile"] = filename
197 200 except (OSError, yaml.YAMLError) as exc:
198 201 msg = f'Cannot read test configuration "{filename}"'
199 202 logger.error(msg)
200 203 raise AppException(msg) from exc
201 204  
202 205 # make test factory
203   - logger.info('Running test factory...')
  206 + logger.info("Running test factory...")
204 207 try:
205 208 self._testfactory = TestFactory(testconf)
206 209 except TestFactoryException as exc:
207 210 logger.error(exc)
208   - raise AppException('Failed to create test factory!') from exc
  211 + raise AppException("Failed to create test factory!") from exc
209 212  
210 213 # ------------------------------------------------------------------------
211 214 async def submit_test(self, uid, ans) -> None:
212   - '''
  215 + """
213 216 Handles test submission and correction.
214 217  
215 218 ans is a dictionary {question_index: answer, ...} with the answers for
216 219 the complete test. For example: {0:'hello', 1:[1,2]}
217   - '''
218   - if self._students[uid]['state'] != 'online':
  220 + """
  221 + if self._students[uid]["state"] != "online":
219 222 logger.warning('"%s" INVALID SUBMISSION! STUDENT NOT ONLINE', uid)
220 223 return
221 224  
222 225 # --- submit answers and correct test
223 226 logger.info('"%s" submitted %d answers', uid, len(ans))
224   - test = self._students[uid]['test']
  227 + test = self._students[uid]["test"]
225 228 test.submit(ans)
226 229  
227   - if test['autocorrect']:
  230 + if test["autocorrect"]:
228 231 await test.correct_async()
229   - logger.info('"%s" grade = %g points', uid, test['grade'])
  232 + logger.info('"%s" grade = %g points', uid, test["grade"])
230 233  
231 234 # --- save test in JSON format
232 235 fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json'
233   - fpath = os.path.join(test['answers_dir'], fname)
  236 + fpath = os.path.join(test["answers_dir"], fname)
234 237 test.save_json(fpath)
235 238 logger.info('"%s" saved JSON', uid)
236 239  
237 240 # --- insert test and questions into the database
238 241 # only corrected questions are added
239 242 test_row = Test(
240   - ref=test['ref'],
241   - title=test['title'],
242   - grade=test['grade'],
243   - state=test['state'],
244   - comment=test['comment'],
245   - starttime=str(test['start_time']),
246   - finishtime=str(test['finish_time']),
  243 + ref=test["ref"],
  244 + title=test["title"],
  245 + grade=test["grade"],
  246 + state=test["state"],
  247 + comment=test["comment"],
  248 + starttime=str(test["start_time"]),
  249 + finishtime=str(test["finish_time"]),
247 250 filename=fpath,
248   - student_id=uid)
  251 + student_id=uid,
  252 + )
249 253  
250   - if test['state'] == 'CORRECTED':
  254 + if test["state"] == "CORRECTED":
251 255 test_row.questions = [
252 256 Question(
253 257 number=n,
254   - ref=q['ref'],
255   - grade=q['grade'],
256   - comment=q.get('comment', ''),
257   - starttime=str(test['start_time']),
258   - finishtime=str(test['finish_time']),
259   - test_id=test['ref']
260   - )
261   - for n, q in enumerate(test['questions'])
262   - ]
  258 + ref=q["ref"],
  259 + grade=q["grade"],
  260 + comment=q.get("comment", ""),
  261 + starttime=str(test["start_time"]),
  262 + finishtime=str(test["finish_time"]),
  263 + test_id=test["ref"],
  264 + )
  265 + for n, q in enumerate(test["questions"])
  266 + ]
263 267  
264 268 with Session(self._engine, future=True) as session:
265 269 session.add(test_row)
... ... @@ -270,21 +274,23 @@ class App():
270 274 def _correct_tests(self) -> None:
271 275 with Session(self._engine, future=True) as session:
272 276 # Find which tests have to be corrected
273   - query = select(Test) \
274   - .where(Test.ref == self._testfactory['ref']) \
275   - .where(Test.state == "SUBMITTED")
  277 + query = (
  278 + select(Test)
  279 + .where(Test.ref == self._testfactory["ref"])
  280 + .where(Test.state == "SUBMITTED")
  281 + )
276 282 dbtests = session.execute(query).scalars().all()
277 283 if not dbtests:
278   - logger.info('No tests to correct')
  284 + logger.info("No tests to correct")
279 285 return
280 286  
281   - logger.info('Correcting %d tests...', len(dbtests))
  287 + logger.info("Correcting %d tests...", len(dbtests))
282 288 for dbtest in dbtests:
283 289 try:
284 290 with open(dbtest.filename) as file:
285 291 testdict = json.load(file)
286 292 except OSError:
287   - logger.error('Failed: %s', dbtest.filename)
  293 + logger.error("Failed: %s", dbtest.filename)
288 294 continue
289 295  
290 296 # creates a class Test with the methods to correct it
... ... @@ -292,31 +298,32 @@ class App():
292 298 # question_from() to produce Question() instances that can be
293 299 # corrected. Finally the test can be corrected.
294 300 test = TestInstance(testdict)
295   - test['questions'] = [question_from(q) for q in test['questions']]
  301 + test["questions"] = [question_from(q) for q in test["questions"]]
296 302 test.correct()
297   - logger.info(' %s: %f', test['student'], test['grade'])
  303 + logger.info(" %s: %f", test["student"], test["grade"])
298 304  
299 305 # save JSON file (overwriting the old one)
300   - uid = test['student']
  306 + uid = test["student"]
301 307 test.save_json(dbtest.filename)
302   - logger.debug('%s saved JSON file', uid)
  308 + logger.debug("%s saved JSON file", uid)
303 309  
304 310 # update database
305   - dbtest.grade = test['grade']
306   - dbtest.state = test['state']
  311 + dbtest.grade = test["grade"]
  312 + dbtest.state = test["state"]
307 313 dbtest.questions = [
308 314 Question(
309 315 number=n,
310   - ref=q['ref'],
311   - grade=q['grade'],
312   - comment=q.get('comment', ''),
313   - starttime=str(test['start_time']),
314   - finishtime=str(test['finish_time']),
315   - test_id=test['ref']
316   - ) for n, q in enumerate(test['questions'])
317   - ]
  316 + ref=q["ref"],
  317 + grade=q["grade"],
  318 + comment=q.get("comment", ""),
  319 + starttime=str(test["start_time"]),
  320 + finishtime=str(test["finish_time"]),
  321 + test_id=test["ref"],
  322 + )
  323 + for n, q in enumerate(test["questions"])
  324 + ]
318 325 session.commit()
319   - logger.info('Database updated')
  326 + logger.info("Database updated")
320 327  
321 328 # ------------------------------------------------------------------------
322 329 # def giveup_test(self, uid):
... ... @@ -348,176 +355,196 @@ class App():
348 355  
349 356 # ------------------------------------------------------------------------
350 357 def register_event(self, uid, cmd, value):
351   - '''handles browser events the occur during the test'''
352   - if cmd == 'focus':
  358 + """handles browser events the occur during the test"""
  359 + if cmd == "focus":
353 360 if value:
354 361 self._focus_student(uid)
355 362 else:
356 363 self._unfocus_student(uid)
357   - elif cmd == 'size':
  364 + elif cmd == "size":
358 365 self._set_screen_area(uid, value)
  366 + elif cmd == "update_answer":
  367 + print(cmd, value) # FIXME
  368 + elif cmd == "lint":
  369 + print(cmd, value) # FIXME
359 370  
360 371 # ========================================================================
361 372 # GETTERS
362 373 # ========================================================================
363 374 def get_test(self, uid: str) -> Optional[dict]:
364   - '''return student test'''
365   - return self._students[uid]['test']
  375 + """return student test"""
  376 + return self._students[uid]["test"]
366 377  
367 378 # ------------------------------------------------------------------------
368 379 def get_name(self, uid: str) -> str:
369   - '''return name of student'''
370   - return self._students[uid]['name']
  380 + """return name of student"""
  381 + return self._students[uid]["name"]
371 382  
372 383 # ------------------------------------------------------------------------
373 384 def get_test_config(self) -> dict:
374   - '''return brief test configuration to use as header in /admin'''
375   - return {'title': self._testfactory['title'],
376   - 'ref': self._testfactory['ref'],
377   - 'filename': self._testfactory['testfile'],
378   - 'database': self._testfactory['database'],
379   - 'answers_dir': self._testfactory['answers_dir']
380   - }
  385 + """return brief test configuration to use as header in /admin"""
  386 + return {
  387 + "title": self._testfactory["title"],
  388 + "ref": self._testfactory["ref"],
  389 + "filename": self._testfactory["testfile"],
  390 + "database": self._testfactory["database"],
  391 + "answers_dir": self._testfactory["answers_dir"],
  392 + }
381 393  
382 394 # ------------------------------------------------------------------------
383 395 def get_grades_csv(self):
384   - '''generates a CSV with the grades of the test currently running'''
385   - test_ref = self._testfactory['ref']
  396 + """generates a CSV with the grades of the test currently running"""
  397 + test_ref = self._testfactory["ref"]
386 398 with Session(self._engine, future=True) as session:
387   - query = select(Test.student_id, Test.grade,
388   - Test.starttime, Test.finishtime)\
389   - .where(Test.ref == test_ref)\
390   - .order_by(Test.student_id)
  399 + query = (
  400 + select(Test.student_id, Test.grade, Test.starttime, Test.finishtime)
  401 + .where(Test.ref == test_ref)
  402 + .order_by(Test.student_id)
  403 + )
391 404 tests = session.execute(query).all()
392 405 if not tests:
393   - logger.warning('Empty CSV: there are no tests!')
394   - return test_ref, ''
  406 + logger.warning("Empty CSV: there are no tests!")
  407 + return test_ref, ""
395 408  
396 409 csvstr = io.StringIO()
397   - writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL)
398   - writer.writerow(('Aluno', 'Nota', 'Início', 'Fim'))
  410 + writer = csv.writer(csvstr, delimiter=";", quoting=csv.QUOTE_ALL)
  411 + writer.writerow(("Aluno", "Nota", "Início", "Fim"))
399 412 writer.writerows(tests)
400 413 return test_ref, csvstr.getvalue()
401 414  
402 415 # ------------------------------------------------------------------------
403 416 def get_detailed_grades_csv(self):
404   - '''generates a CSV with the grades of the test'''
405   - test_ref = self._testfactory['ref']
  417 + """generates a CSV with the grades of the test"""
  418 + test_ref = self._testfactory["ref"]
406 419 with Session(self._engine, future=True) as session:
407   - query = select(Test.id, Test.student_id, Test.starttime,
408   - Question.number, Question.grade)\
409   - .join(Question)\
410   - .where(Test.ref == test_ref)
  420 + query = (
  421 + select(
  422 + Test.id,
  423 + Test.student_id,
  424 + Test.starttime,
  425 + Question.number,
  426 + Question.grade,
  427 + )
  428 + .join(Question)
  429 + .where(Test.ref == test_ref)
  430 + )
411 431 questions = session.execute(query).all()
412 432  
413   - cols = ['Aluno', 'Início']
414   - tests = {} # {test_id: {student_id, starttime, 0: grade, ...}}
  433 + cols = ["Aluno", "Início"]
  434 + tests = {} # {test_id: {student_id, starttime, 0: grade, ...}}
415 435 for test_id, student_id, starttime, num, grade in questions:
416   - default_test_id = {'Aluno': student_id, 'Início': starttime}
  436 + default_test_id = {"Aluno": student_id, "Início": starttime}
417 437 tests.setdefault(test_id, default_test_id)[num] = grade
418 438 if num not in cols:
419 439 cols.append(num)
420 440  
421 441 if not tests:
422   - logger.warning('Empty CSV: there are no tests!')
423   - return test_ref, ''
  442 + logger.warning("Empty CSV: there are no tests!")
  443 + return test_ref, ""
424 444  
425 445 csvstr = io.StringIO()
426   - writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None,
427   - delimiter=';', quoting=csv.QUOTE_ALL)
  446 + writer = csv.DictWriter(
  447 + csvstr, fieldnames=cols, restval=None, delimiter=";", quoting=csv.QUOTE_ALL
  448 + )
428 449 writer.writeheader()
429 450 writer.writerows(tests.values())
430 451 return test_ref, csvstr.getvalue()
431 452  
432 453 # ------------------------------------------------------------------------
433 454 def get_json_filename_of_test(self, test_id):
434   - '''get JSON filename from database given the test_id'''
  455 + """get JSON filename from database given the test_id"""
435 456 with Session(self._engine, future=True) as session:
436 457 query = select(Test.filename).where(Test.id == test_id)
437 458 return session.execute(query).scalar()
438 459  
439 460 # ------------------------------------------------------------------------
440 461 def get_grades(self, uid, ref):
441   - '''get grades of student for a given testid'''
  462 + """get grades of student for a given testid"""
442 463 with Session(self._engine, future=True) as session:
443   - query = select(Test.grade, Test.finishtime, Test.id)\
444   - .where(Test.student_id == uid)\
445   - .where(Test.ref == ref)
  464 + query = (
  465 + select(Test.grade, Test.finishtime, Test.id)
  466 + .where(Test.student_id == uid)
  467 + .where(Test.ref == ref)
  468 + )
446 469 grades = session.execute(query).all()
447 470 return [tuple(grade) for grade in grades]
448 471  
449 472 # ------------------------------------------------------------------------
450 473 def get_students_state(self) -> list:
451   - '''get list of states of every student to show in /admin page'''
452   - return [{ 'uid': uid,
453   - 'name': student['name'],
454   - 'allowed': student['state'] == 'allowed',
455   - 'online': student['state'] == 'online',
456   - 'start_time': student.get('test', {}).get('start_time', ''),
457   - 'unfocus': student.get('unfocus', False),
458   - 'area': student.get('area', 1.0),
459   - 'grades': self.get_grades(uid, self._testfactory['ref']) }
460   - for uid, student in self._students.items()]
  474 + """get list of states of every student to show in /admin page"""
  475 + return [
  476 + {
  477 + "uid": uid,
  478 + "name": student["name"],
  479 + "allowed": student["state"] == "allowed",
  480 + "online": student["state"] == "online",
  481 + "start_time": student.get("test", {}).get("start_time", ""),
  482 + "unfocus": student.get("unfocus", False),
  483 + "area": student.get("area", 1.0),
  484 + "grades": self.get_grades(uid, self._testfactory["ref"]),
  485 + }
  486 + for uid, student in self._students.items()
  487 + ]
461 488  
462 489 # ========================================================================
463 490 # SETTERS
464 491 # ========================================================================
465 492 def allow_student(self, uid: str) -> None:
466   - '''allow a single student to login'''
467   - self._students[uid]['state'] = 'allowed'
  493 + """allow a single student to login"""
  494 + self._students[uid]["state"] = "allowed"
468 495 logger.info('"%s" allowed to login', uid)
469 496  
470 497 # ------------------------------------------------------------------------
471 498 def deny_student(self, uid: str) -> None:
472   - '''deny a single student to login'''
  499 + """deny a single student to login"""
473 500 student = self._students[uid]
474   - if student['state'] == 'allowed':
475   - student['state'] = 'offline'
  501 + if student["state"] == "allowed":
  502 + student["state"] = "offline"
476 503 logger.info('"%s" denied to login', uid)
477 504  
478 505 # ------------------------------------------------------------------------
479 506 def allow_all_students(self) -> None:
480   - '''allow all students to login'''
  507 + """allow all students to login"""
481 508 for student in self._students.values():
482   - student['state'] = 'allowed'
483   - logger.info('Allowed %d students', len(self._students))
  509 + student["state"] = "allowed"
  510 + logger.info("Allowed %d students", len(self._students))
484 511  
485 512 # ------------------------------------------------------------------------
486 513 def deny_all_students(self) -> None:
487   - '''deny all students to login'''
488   - logger.info('Denying all students...')
  514 + """deny all students to login"""
  515 + logger.info("Denying all students...")
489 516 for student in self._students.values():
490   - if student['state'] == 'allowed':
491   - student['state'] = 'offline'
  517 + if student["state"] == "allowed":
  518 + student["state"] = "offline"
492 519  
493 520 # ------------------------------------------------------------------------
494 521 async def insert_new_student(self, uid: str, name: str) -> None:
495   - '''insert new student into the database'''
  522 + """insert new student into the database"""
496 523 with Session(self._engine, future=True) as session:
497 524 try:
498   - session.add(Student(id=uid, name=name, password=''))
  525 + session.add(Student(id=uid, name=name, password=""))
499 526 session.commit()
500 527 except IntegrityError:
501 528 logger.warning('"%s" already exists!', uid)
502 529 session.rollback()
503 530 return
504   - logger.info('New student added: %s %s', uid, name)
  531 + logger.info("New student added: %s %s", uid, name)
505 532 self._students[uid] = {
506   - 'name': name,
507   - 'state': 'offline',
508   - 'test': await self._testfactory.generate(),
509   - }
  533 + "name": name,
  534 + "state": "offline",
  535 + "test": await self._testfactory.generate(),
  536 + }
510 537  
511 538 # ------------------------------------------------------------------------
512 539 def allow_from_list(self, filename: str) -> None:
513   - '''allow students listed in text file (one number per line)'''
  540 + """allow students listed in text file (one number per line)"""
514 541 # parse list of students to allow (one number per line)
515 542 try:
516   - with open(filename, 'r', encoding='utf-8') as file:
  543 + with open(filename, "r", encoding="utf-8") as file:
517 544 allowed = {line.strip() for line in file}
518   - allowed.discard('')
  545 + allowed.discard("")
519 546 except OSError as exc:
520   - error_msg = f'Cannot read file {filename}'
  547 + error_msg = f"Cannot read file {filename}"
521 548 logger.critical(error_msg)
522 549 raise AppException(error_msg) from exc
523 550  
... ... @@ -530,27 +557,34 @@ class App():
530 557 logger.warning('Allowed student "%s" does not exist!', uid)
531 558 missing += 1
532 559  
533   - logger.info('Allowed %d students', len(allowed)-missing)
  560 + logger.info("Allowed %d students", len(allowed) - missing)
534 561 if missing:
535   - logger.warning(' %d missing!', missing)
  562 + logger.warning(" %d missing!", missing)
536 563  
537 564 # ------------------------------------------------------------------------
538 565 def _focus_student(self, uid):
539   - '''set student in focus state'''
540   - self._students[uid]['unfocus'] = False
  566 + """set student in focus state"""
  567 + self._students[uid]["unfocus"] = False
541 568 logger.info('"%s" focus', uid)
542 569  
543 570 # ------------------------------------------------------------------------
544 571 def _unfocus_student(self, uid):
545   - '''set student in unfocus state'''
546   - self._students[uid]['unfocus'] = True
  572 + """set student in unfocus state"""
  573 + self._students[uid]["unfocus"] = True
547 574 logger.info('"%s" unfocus', uid)
548 575  
549 576 # ------------------------------------------------------------------------
550 577 def _set_screen_area(self, uid, sizes):
551   - '''set current browser area as detected in resize event'''
  578 + """set current browser area as detected in resize event"""
552 579 scr_y, scr_x, win_y, win_x = sizes
553 580 area = win_x * win_y / (scr_x * scr_y) * 100
554   - self._students[uid]['area'] = area
555   - logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d',
556   - uid, area, win_x, win_y, scr_x, scr_y)
  581 + self._students[uid]["area"] = area
  582 + logger.info(
  583 + '"%s" area=%g%%, window=%dx%d, screen=%dx%d',
  584 + uid,
  585 + area,
  586 + win_x,
  587 + win_y,
  588 + scr_x,
  589 + scr_y,
  590 + )
... ...
perguntations/initdb.py
1 1 #!/usr/bin/env python3
2 2  
3   -'''
  3 +"""
4 4 Commandline utility to initialize and update student database
5   -'''
  5 +"""
6 6  
7 7 # base
8 8 import csv
... ... @@ -22,77 +22,84 @@ from .models import Base, Student
22 22  
23 23 # ============================================================================
24 24 def parse_commandline_arguments():
25   - '''Parse command line options'''
  25 + """Parse command line options"""
26 26  
27 27 parser = argparse.ArgumentParser(
28 28 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
29   - description='Insert new users into a database. Users can be imported '
30   - 'from CSV files in the SIIUE format or defined in the '
31   - 'command line. If the database does not exist, a new one '
32   - 'is created.')
33   -
34   - parser.add_argument('csvfile',
35   - nargs='*',
36   - type=str,
37   - default='',
38   - help='CSV file to import (SIIUE)')
39   -
40   - parser.add_argument('--db',
41   - default='students.db',
42   - type=str,
43   - help='database file')
44   -
45   - parser.add_argument('-A', '--admin',
46   - action='store_true',
47   - help='insert admin user 0 "Admin"')
48   -
49   - parser.add_argument('-a', '--add',
50   - nargs=2,
51   - action='append',
52   - metavar=('uid', 'name'),
53   - help='add new user id and name')
54   -
55   - parser.add_argument('-u', '--update',
56   - nargs='+',
57   - metavar='uid',
58   - default=[],
59   - help='list of users whose password is to be updated')
60   -
61   - parser.add_argument('-U', '--update-all',
62   - action='store_true',
63   - help='all except admin will have the password updated')
64   -
65   - parser.add_argument('--pw',
66   - default=None,
67   - type=str,
68   - help='password for new or updated users')
69   -
70   - parser.add_argument('-V', '--verbose',
71   - action='store_true',
72   - help='show all students in database')
  29 + description="Insert new users into a database. Users can be imported "
  30 + "from CSV files in the SIIUE format or defined in the "
  31 + "command line. If the database does not exist, a new one "
  32 + "is created.",
  33 + )
  34 +
  35 + parser.add_argument(
  36 + "csvfile", nargs="*", type=str, default="", help="CSV file to import (SIIUE)"
  37 + )
  38 +
  39 + parser.add_argument("--db", default="students.db", type=str, help="database file")
  40 +
  41 + parser.add_argument(
  42 + "-A", "--admin", action="store_true", help='insert admin user 0 "Admin"'
  43 + )
  44 +
  45 + parser.add_argument(
  46 + "-a",
  47 + "--add",
  48 + nargs=2,
  49 + action="append",
  50 + metavar=("uid", "name"),
  51 + help="add new user id and name",
  52 + )
  53 +
  54 + parser.add_argument(
  55 + "-u",
  56 + "--update",
  57 + nargs="+",
  58 + metavar="uid",
  59 + default=[],
  60 + help="list of users whose password is to be updated",
  61 + )
  62 +
  63 + parser.add_argument(
  64 + "-U",
  65 + "--update-all",
  66 + action="store_true",
  67 + help="all except admin will have the password updated",
  68 + )
  69 +
  70 + parser.add_argument(
  71 + "--pw", default=None, type=str, help="password for new or updated users"
  72 + )
  73 +
  74 + parser.add_argument(
  75 + "-V", "--verbose", action="store_true", help="show all students in database"
  76 + )
73 77  
74 78 return parser.parse_args()
75 79  
76 80  
77 81 # ============================================================================
78 82 def get_students_from_csv(filename: str):
79   - '''
  83 + """
80 84 SIIUE names have alien strings like "(TE)" and are sometimes capitalized
81 85 We remove them so that students dont keep asking what it means
82   - '''
  86 + """
83 87 csv_settings = {
84   - 'delimiter': ';',
85   - 'quotechar': '"',
86   - 'skipinitialspace': True,
87   - }
  88 + "delimiter": ";",
  89 + "quotechar": '"',
  90 + "skipinitialspace": True,
  91 + }
88 92  
89 93 try:
90   - with open(filename, encoding='iso-8859-1') as file:
  94 + with open(filename, encoding="iso-8859-1") as file:
91 95 csvreader = csv.DictReader(file, **csv_settings)
92   - students = [{
93   - 'uid': s['N.º'],
94   - 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
95   - } for s in csvreader]
  96 + students = [
  97 + {
  98 + "uid": s["N.º"],
  99 + "name": capwords(re.sub(r"\(.*\)", "", s["Nome"]).strip()),
  100 + }
  101 + for s in csvreader
  102 + ]
96 103 except OSError:
97 104 print(f'!!! Error reading file "{filename}" !!!')
98 105 students = []
... ... @@ -103,79 +110,80 @@ def get_students_from_csv(filename: str):
103 110 return students
104 111  
105 112  
106   -# ============================================================================
107 113 def insert_students_into_db(session, students) -> None:
108   - '''insert list of students into the database'''
  114 + """insert list of students into the database"""
109 115 try:
110   - session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw'])
111   - for s in students])
  116 + session.add_all(
  117 + [Student(id=s["uid"], name=s["name"], password=s["pw"]) for s in students]
  118 + )
112 119 session.commit()
113 120 except IntegrityError:
114   - print('!!! Integrity error. Users already in database. Aborted !!!\n')
  121 + print("!!! Integrity error. Users already in database. Aborted !!!\n")
115 122 session.rollback()
116 123  
117 124  
118 125 # ============================================================================
119 126 def show_students_in_database(session, verbose=False):
120   - '''get students from database'''
  127 + """get students from database"""
121 128 users = session.execute(select(Student)).scalars().all()
122 129 total = len(users)
123 130  
124   - print('Registered users:')
  131 + print("Registered users:")
125 132 if total == 0:
126   - print(' -- none --')
  133 + print(" -- none --")
127 134 else:
128   - users.sort(key=lambda u: f'{u.id:>12}') # sort by number
  135 + users.sort(key=lambda u: f"{u.id:>12}") # sort by number
129 136 if verbose:
130 137 for user in users:
131   - print(f'{user.id:>12} {user.name}')
  138 + print(f"{user.id:>12} {user.name}")
132 139 else:
133   - print(f'{users[0].id:>12} {users[0].name}')
  140 + print(f"{users[0].id:>12} {users[0].name}")
134 141 if total > 1:
135   - print(f'{users[1].id:>12} {users[1].name}')
  142 + print(f"{users[1].id:>12} {users[1].name}")
136 143 if total > 3:
137   - print(' | |')
  144 + print(" | |")
138 145 if total > 2:
139   - print(f'{users[-1].id:>12} {users[-1].name}')
140   - print(f'Total: {total}.')
  146 + print(f"{users[-1].id:>12} {users[-1].name}")
  147 + print(f"Total: {total}.")
141 148  
142 149  
143 150 # ============================================================================
144 151 def main():
145   - '''insert, update, show students from database'''
  152 + """insert, update, show students from database"""
146 153  
147 154 ph = PasswordHasher()
148 155 args = parse_commandline_arguments()
149 156  
150 157 # --- database
151   - print(f'Database: {args.db}')
152   - engine = create_engine(f'sqlite:///{args.db}', echo=False, future=True)
  158 + print(f"Database: {args.db}")
  159 + engine = create_engine(f"sqlite:///{args.db}", echo=False, future=True)
153 160 Base.metadata.create_all(engine) # Criates schema if needed
154   - session = Session(engine, future=True)
  161 + session = Session(engine, future=True) # FIXME: future?
155 162  
156 163 # --- build list of new students to insert
157 164 students = []
158 165  
159 166 if args.admin:
160   - print('Adding administrator: 0, Admin')
161   - students.append({'uid': '0', 'name': 'Admin'})
  167 + print("Adding user: 0, Admin.")
  168 + students.append({"uid": "0", "name": "Admin"})
162 169  
163 170 for csvfile in args.csvfile:
164   - print(f'Adding users from: {csvfile}')
  171 + print("Adding users from:", csvfile)
165 172 students.extend(get_students_from_csv(csvfile))
166 173  
167 174 if args.add:
168 175 for uid, name in args.add:
169   - print(f'Adding user: {uid}, {name}.')
170   - students.append({'uid': uid, 'name': name})
  176 + print(f"Adding user: {uid}, {name}.")
  177 + students.append({"uid": uid, "name": name})
171 178  
172 179 # --- insert new students
173 180 if students:
174   - print('Generating password hashes')
175 181 if args.pw is None:
  182 + print('Set passwords to empty')
176 183 for s in students:
177 184 s['pw'] = ''
178 185 else:
  186 + print('Generating password hashes')
179 187 for s in students:
180 188 s['pw'] = ph.hash(args.pw)
181 189 print('.', end='', flush=True)
... ... @@ -184,28 +192,31 @@ def main():
184 192  
185 193 # --- update all students
186 194 if args.update_all:
187   - all_students = session.execute(
188   - select(Student).where(Student.id != '0')
189   - ).scalars().all()
190   -
191   - print(f'Updating password of {len(all_students)} users')
192   - for student in all_students:
193   - password = (args.pw or student.id).encode('utf-8')
194   - student.password = ph.hash(password)
195   - print('.', end='', flush=True)
196   - print()
  195 + query = select(Student).where(Student.id != '0')
  196 + all_students = session.execute(query).scalars().all()
  197 + if args.pw is None:
  198 + print(f'Resetting password of {len(all_students)} users')
  199 + for student in all_students:
  200 + student.password = ''
  201 + else:
  202 + print(f'Updating password of {len(all_students)} users')
  203 + for student in all_students:
  204 + student.password = ph.hash(args.pw)
  205 + print('.', end='', flush=True)
  206 + print()
197 207 session.commit()
198 208  
199   - # --- update some students
  209 + # --- update only specified students
200 210 else:
201 211 for student_id in args.update:
202   - print(f'Updating password of {student_id}')
203   - student = session.execute(
204   - select(Student).
205   - where(Student.id == student_id)
206   - ).scalar_one()
207   - new_password = (args.pw or student_id).encode('utf-8')
208   - student.password = ph.hash(new_password)
  212 + query = select(Student).where(Student.id == student_id)
  213 + student = session.execute(query).scalar_one()
  214 + if args.pw is None:
  215 + print(f'Resetting password of user {student_id}')
  216 + student.password = ''
  217 + else:
  218 + print(f'Updating password of user {student_id}')
  219 + student.password = ph.hash(args.pw)
209 220 session.commit()
210 221  
211 222 show_students_in_database(session, args.verbose)
... ... @@ -214,5 +225,5 @@ def main():
214 225  
215 226  
216 227 # ============================================================================
217   -if __name__ == '__main__':
  228 +if __name__ == "__main__":
218 229 main()
... ...
perguntations/main.py
1 1 #!/usr/bin/env python3
2 2  
3   -'''
  3 +"""
4 4 Start application and web server
5   -'''
  5 +"""
6 6  
7 7  
8 8 # python standard library
... ... @@ -21,126 +21,157 @@ from . import APP_NAME, APP_VERSION
21 21  
22 22 # ----------------------------------------------------------------------------
23 23 def parse_cmdline_arguments() -> argparse.Namespace:
24   - '''
  24 + """
25 25 Get command line arguments
26   - '''
  26 + """
27 27 parser = argparse.ArgumentParser(
28   - description='Server for online tests. Enrolled students and tests '
29   - 'have to be previously configured. Please read the documentation '
30   - 'included with this software before running the server.')
31   - parser.add_argument('testfile',
32   - type=str,
33   - help='tests in YAML format')
34   - parser.add_argument('--allow-all',
35   - action='store_true',
36   - help='Allow all students to login immediately')
37   - parser.add_argument('--allow-list',
38   - type=str,
39   - help='File with list of students to allow immediately')
40   - parser.add_argument('--debug',
41   - action='store_true',
42   - help='Enable debug messages')
43   - parser.add_argument('--review',
44   - action='store_true',
45   - help='Review mode: doesn\'t generate test')
46   - parser.add_argument('--correct',
47   - action='store_true',
48   - help='Correct test and update JSON files and database')
49   - parser.add_argument('--port',
50   - type=int,
51   - default=8443,
52   - help='port for the HTTPS server (default: 8443)')
53   - parser.add_argument('--version',
54   - action='version',
55   - version=f'{APP_VERSION} - python {sys.version}',
56   - help='Show version information and exit')
  28 + description="Server for online tests. Enrolled students and tests "
  29 + "have to be previously configured. Please read the documentation "
  30 + "included with this software before running the server."
  31 + )
  32 + parser.add_argument(
  33 + "testfile",
  34 + type=str,
  35 + help="test configuration in YAML format",
  36 + )
  37 + parser.add_argument(
  38 + "--allow-all",
  39 + action="store_true",
  40 + help="Allow login for all students",
  41 + )
  42 + parser.add_argument(
  43 + "--allow-list",
  44 + type=str,
  45 + help="File with list of students to allow immediately",
  46 + )
  47 + parser.add_argument(
  48 + "--debug",
  49 + action="store_true",
  50 + help="Enable debug mode in the logger and webserver",
  51 + )
  52 + parser.add_argument(
  53 + "--review",
  54 + action="store_true",
  55 + help="Fast start by not generating tests",
  56 + )
  57 + parser.add_argument(
  58 + "--correct",
  59 + action="store_true",
  60 + help="Correct test and update JSON files and database",
  61 + )
  62 + parser.add_argument(
  63 + "--port",
  64 + type=int,
  65 + default=8443,
  66 + help="port for the HTTPS server (default: 8443)",
  67 + )
  68 + parser.add_argument(
  69 + "--version",
  70 + action="version",
  71 + version=f"{APP_VERSION} - python {sys.version}",
  72 + help="Show version information and exit",
  73 + )
57 74 return parser.parse_args()
58 75  
  76 +
59 77 # ----------------------------------------------------------------------------
60 78 def get_logger_config(debug=False) -> dict:
61   - '''
  79 + """
62 80 Load logger configuration from ~/.config directory if exists,
63 81 otherwise set default paramenters.
64   - '''
  82 + """
65 83  
66   - file = 'logger-debug.yaml' if debug else 'logger.yaml'
67   - path = os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config/'))
  84 + file = "logger-debug.yaml" if debug else "logger.yaml"
  85 + path = os.path.expanduser(os.environ.get("XDG_CONFIG_HOME", "~/.config/"))
68 86 try:
69 87 return load_yaml(os.path.join(path, APP_NAME, file))
70 88 except OSError:
71   - print('Using default logger configuration...')
  89 + print("Using default logger configuration...")
72 90  
73 91 if debug:
74   - level = 'DEBUG'
75   - fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s'
76   - dateformat = ''
  92 + level = "DEBUG"
  93 + fmt = (
  94 + "%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s"
  95 + )
  96 + dateformat = ""
77 97 else:
78   - level = 'INFO'
79   - fmt = '%(asctime)s| %(levelname)-8s| %(message)s'
80   - dateformat = '%Y-%m-%d %H:%M:%S'
81   - modules = ['main', 'serve', 'app', 'models', 'questions', 'test',
82   - 'testfactory', 'tools']
83   - logger = {'handlers': ['default'], 'level': level, 'propagate': False}
  98 + level = "INFO"
  99 + fmt = "%(asctime)s| %(levelname)-8s| %(message)s"
  100 + dateformat = "%Y-%m-%d %H:%M:%S"
  101 + modules = [
  102 + "main",
  103 + "serve",
  104 + "app",
  105 + "models",
  106 + "questions",
  107 + "test",
  108 + "testfactory",
  109 + "tools",
  110 + ]
  111 + logger = {"handlers": ["default"], "level": level, "propagate": False}
84 112 return {
85   - 'version': 1,
86   - 'formatters': {
87   - 'standard': {
88   - 'format': fmt,
89   - 'datefmt': dateformat,
90   - },
91   - },
92   - 'handlers': {
93   - 'default': {
94   - 'level': level,
95   - 'class': 'logging.StreamHandler',
96   - 'formatter': 'standard',
97   - 'stream': 'ext://sys.stdout',
98   - },
99   - },
100   - 'loggers': {f'{APP_NAME}.{module}': logger for module in modules}
101   - }
  113 + "version": 1,
  114 + "formatters": {
  115 + "standard": {
  116 + "format": fmt,
  117 + "datefmt": dateformat,
  118 + },
  119 + },
  120 + "handlers": {
  121 + "default": {
  122 + "level": level,
  123 + "class": "logging.StreamHandler",
  124 + "formatter": "standard",
  125 + "stream": "ext://sys.stdout",
  126 + },
  127 + },
  128 + "loggers": {f"{APP_NAME}.{module}": logger for module in modules},
  129 + }
  130 +
102 131  
103 132 # ----------------------------------------------------------------------------
104 133 def main() -> None:
105   - '''
  134 + """
106 135 Tornado web server
107   - '''
  136 + """
108 137 args = parse_cmdline_arguments()
109 138  
110 139 # --- Setup logging ------------------------------------------------------
111 140 logging.config.dictConfig(get_logger_config(args.debug))
112 141 logger = logging.getLogger(__name__)
113 142  
114   - logger.info('================== Start Logging ==================')
  143 + logger.info("================== Start Logging ==================")
115 144  
116 145 # --- start application --------------------------------------------------
117 146 config = {
118   - 'testfile': args.testfile,
119   - 'allow_all': args.allow_all,
120   - 'allow_list': args.allow_list,
121   - 'debug': args.debug,
122   - 'review': args.review,
123   - 'correct': args.correct,
124   - }
  147 + "testfile": args.testfile,
  148 + "allow_all": args.allow_all,
  149 + "allow_list": args.allow_list,
  150 + "debug": args.debug,
  151 + "review": args.review,
  152 + "correct": args.correct,
  153 + }
125 154  
126 155 try:
127 156 app = App(config)
128 157 except AppException:
129   - logger.critical('Failed to start application!')
  158 + logger.critical("Failed to start application!")
130 159 sys.exit(1)
131 160  
132 161 # --- get SSL certificates -----------------------------------------------
133   - if 'XDG_DATA_HOME' in os.environ:
134   - certs_dir = os.path.join(os.environ['XDG_DATA_HOME'], 'certs')
  162 + if "XDG_DATA_HOME" in os.environ:
  163 + certs_dir = os.path.join(os.environ["XDG_DATA_HOME"], "certs")
135 164 else:
136   - certs_dir = os.path.expanduser('~/.local/share/certs')
  165 + certs_dir = os.path.expanduser("~/.local/share/certs")
137 166  
138 167 ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
139 168 try:
140   - ssl_opt.load_cert_chain(os.path.join(certs_dir, 'cert.pem'),
141   - os.path.join(certs_dir, 'privkey.pem'))
  169 + ssl_opt.load_cert_chain(
  170 + os.path.join(certs_dir, "cert.pem"),
  171 + os.path.join(certs_dir, "privkey.pem"),
  172 + )
142 173 except FileNotFoundError:
143   - logger.critical('SSL certificates missing in %s', certs_dir)
  174 + logger.critical("SSL certificates missing in %s", certs_dir)
144 175 sys.exit(1)
145 176  
146 177 # --- run webserver ------------------------------------------------------
... ...
perguntations/models.py
1   -'''
  1 +"""
2 2 perguntations/models.py
3 3 SQLAlchemy ORM
4   -'''
  4 +"""
5 5  
6 6 from typing import List
7 7  
... ... @@ -40,6 +40,7 @@ class Test(Base):
40 40 filename: Mapped[str]
41 41 student_id = mapped_column(String, ForeignKey('students.id'))
42 42 # ---
  43 +
43 44 student: Mapped['Student'] = relationship(back_populates='tests')
44 45 questions: Mapped[List['Question']] = relationship(back_populates='test')
45 46  
... ... @@ -56,4 +57,5 @@ class Question(Base):
56 57 finishtime: Mapped[str]
57 58 test_id = mapped_column(String, ForeignKey('tests.id'))
58 59 # ---
  60 +
59 61 test: Mapped['Test'] = relationship(back_populates='questions')
... ...
perguntations/parser_markdown.py
1   -
2   -'''
  1 +"""
3 2 Parse markdown and generate HTML
4 3 Includes support for LaTeX formulas
5   -'''
  4 +"""
6 5  
7 6  
8 7 # python standard library
... ... @@ -26,21 +25,26 @@ logger = logging.getLogger(__name__)
26 25 # Block math: $$x$$ or \begin{equation}x\end{equation}
27 26 # -------------------------------------------------------------------------
28 27 class MathBlockGrammar(mistune.BlockGrammar):
29   - '''
  28 + """
30 29 match block math $$x$$ and math environments begin{} end{}
31   - '''
  30 + """
  31 +
32 32 # pylint: disable=too-few-public-methods
33 33 block_math = re.compile(r"^\$\$(.*?)\$\$", re.DOTALL)
34   - latex_environment = re.compile(r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}",
35   - re.DOTALL)
  34 + latex_environment = re.compile(
  35 + r"^\\begin\{([a-z]*\*?)\}(.*?)\\end\{\1\}", re.DOTALL
  36 + )
36 37  
37 38  
38 39 class MathBlockLexer(mistune.BlockLexer):
39   - '''
  40 + """
40 41 parser for block math and latex environment
41   - '''
42   - default_rules = ['block_math', 'latex_environment'] \
43   - + mistune.BlockLexer.default_rules
  42 + """
  43 +
  44 + default_rules = [
  45 + "block_math",
  46 + "latex_environment",
  47 + ] + mistune.BlockLexer.default_rules
44 48  
45 49 def __init__(self, rules=None, **kwargs):
46 50 if rules is None:
... ... @@ -48,36 +52,37 @@ class MathBlockLexer(mistune.BlockLexer):
48 52 super().__init__(rules, **kwargs)
49 53  
50 54 def parse_block_math(self, math):
51   - '''Parse a $$math$$ block'''
52   - self.tokens.append({
53   - 'type': 'block_math',
54   - 'text': math.group(1)
55   - })
  55 + """Parse a $$math$$ block"""
  56 + self.tokens.append({"type": "block_math", "text": math.group(1)})
56 57  
57 58 def parse_latex_environment(self, math):
58   - '''Parse latex environment in formula'''
59   - self.tokens.append({
60   - 'type': 'latex_environment',
61   - 'name': math.group(1),
62   - 'text': math.group(2)
63   - })
  59 + """Parse latex environment in formula"""
  60 + self.tokens.append(
  61 + {
  62 + "type": "latex_environment",
  63 + "name": math.group(1),
  64 + "text": math.group(2),
  65 + }
  66 + )
64 67  
65 68  
66 69 class MathInlineGrammar(mistune.InlineGrammar):
67   - '''
  70 + """
68 71 match inline math $x$, block math $$x$$ and text
69   - '''
  72 + """
  73 +
70 74 # pylint: disable=too-few-public-methods
71 75 math = re.compile(r"^\$(.+?)\$", re.DOTALL)
72 76 block_math = re.compile(r"^\$\$(.+?)\$\$", re.DOTALL)
73   - text = re.compile(r'^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)')
  77 + text = re.compile(r"^[\s\S]+?(?=[\\<!\[_*`~$]|https?://| {2,}\n|$)")
74 78  
75 79  
76 80 class MathInlineLexer(mistune.InlineLexer):
77   - '''
  81 + """
78 82 render output math
79   - '''
80   - default_rules = ['block_math', 'math'] + mistune.InlineLexer.default_rules
  83 + """
  84 +
  85 + default_rules = ["block_math", "math"] + mistune.InlineLexer.default_rules
81 86  
82 87 def __init__(self, renderer, rules=None, **kwargs):
83 88 if rules is None:
... ... @@ -85,70 +90,75 @@ class MathInlineLexer(mistune.InlineLexer):
85 90 super().__init__(renderer, rules, **kwargs)
86 91  
87 92 def output_math(self, math):
88   - '''render inline math'''
  93 + """render inline math"""
89 94 return self.renderer.inline_math(math.group(1))
90 95  
91 96 def output_block_math(self, math):
92   - '''render block math'''
  97 + """render block math"""
93 98 return self.renderer.block_math(math.group(1))
94 99  
95 100  
96 101 class MarkdownWithMath(mistune.Markdown):
97   - '''
  102 + """
98 103 render ouput latex
99   - '''
  104 + """
  105 +
100 106 def __init__(self, renderer, **kwargs):
101   - if 'inline' not in kwargs:
102   - kwargs['inline'] = MathInlineLexer
103   - if 'block' not in kwargs:
104   - kwargs['block'] = MathBlockLexer
  107 + if "inline" not in kwargs:
  108 + kwargs["inline"] = MathInlineLexer
  109 + if "block" not in kwargs:
  110 + kwargs["block"] = MathBlockLexer
105 111 super().__init__(renderer, **kwargs)
106 112  
107 113 def output_block_math(self):
108   - '''render block math'''
109   - return self.renderer.block_math(self.token['text'])
  114 + """render block math"""
  115 + return self.renderer.block_math(self.token["text"])
110 116  
111 117 def output_latex_environment(self):
112   - '''render latex environment'''
113   - return self.renderer.latex_environment(self.token['name'],
114   - self.token['text'])
  118 + """render latex environment"""
  119 + return self.renderer.latex_environment(self.token["name"], self.token["text"])
115 120  
116 121  
117 122 class HighlightRenderer(mistune.Renderer):
118   - '''
  123 + """
119 124 images, tables, block code
120   - '''
121   - def __init__(self, qref='.'):
  125 + """
  126 +
  127 + def __init__(self, qref="."):
122 128 super().__init__(escape=True)
123 129 self.qref = qref
124 130  
125   - def block_code(self, code, lang='text'):
126   - '''render code block with syntax highlight'''
  131 + def block_code(self, code, lang="text"):
  132 + """render code block with syntax highlight"""
127 133 try:
128 134 lexer = get_lexer_by_name(lang, stripall=False)
129 135 except Exception:
130   - lexer = get_lexer_by_name('text', stripall=False)
  136 + lexer = get_lexer_by_name("text", stripall=False)
131 137  
132 138 formatter = HtmlFormatter()
133 139 return highlight(code, lexer, formatter)
134 140  
135 141 def table(self, header, body):
136   - '''render table'''
137   - return '<table class="table table-sm"><thead class="thead-light">' \
138   - + header + '</thead><tbody>' + body + '</tbody></table>'
  142 + """render table"""
  143 + return (
  144 + '<table class="table table-sm"><thead class="thead-light">'
  145 + + header
  146 + + "</thead><tbody>"
  147 + + body
  148 + + "</tbody></table>"
  149 + )
139 150  
140 151 def image(self, src, title, text):
141   - '''render image'''
  152 + """render image"""
142 153 alt = mistune.escape(text, quote=True)
143 154 if title is not None:
144 155 if title: # not empty string, show as caption
145 156 title = mistune.escape(title, quote=True)
146   - caption = f'<figcaption class="figure-caption">{title}' \
147   - '</figcaption>'
  157 + caption = f'<figcaption class="figure-caption">{title}' "</figcaption>"
148 158 else: # title is an empty string, show as centered figure
149   - caption = ''
  159 + caption = ""
150 160  
151   - return f'''
  161 + return f"""
152 162 <div class="text-center">
153 163 <figure class="figure">
154 164 <img src="/file?ref={self.qref}&image={src}"
... ... @@ -157,31 +167,31 @@ class HighlightRenderer(mistune.Renderer):
157 167 {caption}
158 168 </figure>
159 169 </div>
160   - '''
  170 + """
161 171  
162 172 # title indefined, show as inline image
163   - return f'''
  173 + return f"""
164 174 <img src="/file?ref={self.qref}&image={src}"
165 175 class="figure-img img-fluid" alt="{alt}" title="{title}">
166   - '''
  176 + """
167 177  
168 178 # Pass math through unaltered - mathjax does the rendering in the browser
169 179 def block_math(self, text):
170   - '''bypass block math'''
  180 + """bypass block math"""
171 181 # pylint: disable=no-self-use
172   - return fr'$$ {text} $$'
  182 + return rf"$$ {text} $$"
173 183  
174 184 def latex_environment(self, name, text):
175   - '''bypass latex environment'''
  185 + """bypass latex environment"""
176 186 # pylint: disable=no-self-use
177   - return fr'\begin{{{name}}} {text} \end{{{name}}}'
  187 + return rf"\begin{{{name}}} {text} \end{{{name}}}"
178 188  
179 189 def inline_math(self, text):
180   - '''bypass inline math'''
  190 + """bypass inline math"""
181 191 # pylint: disable=no-self-use
182   - return fr'$$$ {text} $$$'
  192 + return rf"$$$ {text} $$$"
183 193  
184 194  
185   -def md_to_html(qref='.'):
186   - '''markdown to html interface'''
  195 +def md_to_html(qref="."):
  196 + """markdown to html interface"""
187 197 return MarkdownWithMath(HighlightRenderer(qref=qref))
... ...
perguntations/questions.py
1   -'''
  1 +"""
2 2 File: perguntations/questions.py
3 3 Description: Classes the implement several types of questions.
4   -'''
  4 +"""
5 5  
6 6  
7 7 # python standard library
... ... @@ -19,11 +19,11 @@ from .tools import run_script, run_script_async
19 19 # setup logger for this module
20 20 logger = logging.getLogger(__name__)
21 21  
22   -QDict = NewType('QDict', Dict[str, Any])
  22 +QDict = NewType("QDict", Dict[str, Any])
23 23  
24 24  
25 25 class QuestionException(Exception):
26   - '''Exceptions raised in this module'''
  26 + """Exceptions raised in this module"""
27 27  
28 28  
29 29 # ============================================================================
... ... @@ -31,68 +31,72 @@ class QuestionException(Exception):
31 31 # presented to students.
32 32 # ============================================================================
33 33 class Question(dict):
34   - '''
  34 + """
35 35 Classes derived from this base class are meant to instantiate questions
36 36 for each student.
37 37 Instances can shuffle options or automatically generate questions.
38   - '''
  38 + """
39 39  
40 40 def gen(self) -> None:
41   - '''
  41 + """
42 42 Sets defaults that are valid for any question type
43   - '''
  43 + """
44 44  
45 45 # add required keys if missing
46   - self.set_defaults(QDict({
47   - 'title': '',
48   - 'answer': None,
49   - 'comments': '',
50   - 'solution': '',
51   - 'files': {},
52   - }))
  46 + self.set_defaults(
  47 + QDict(
  48 + {
  49 + "title": "",
  50 + "answer": None,
  51 + "comments": "",
  52 + "solution": "",
  53 + "files": {},
  54 + }
  55 + )
  56 + )
53 57  
54 58 def set_answer(self, ans) -> None:
55   - '''set answer field and register time'''
56   - self['answer'] = ans
57   - self['finish_time'] = datetime.now()
  59 + """set answer field and register time"""
  60 + self["answer"] = ans
  61 + self["finish_time"] = datetime.now()
58 62  
59 63 def correct(self) -> None:
60   - '''default correction (synchronous version)'''
61   - self['comments'] = ''
62   - self['grade'] = 0.0
  64 + """default correction (synchronous version)"""
  65 + self["comments"] = ""
  66 + self["grade"] = 0.0
63 67  
64 68 async def correct_async(self) -> None:
65   - '''default correction (async version)'''
  69 + """default correction (async version)"""
66 70 self.correct()
67 71  
68 72 def set_defaults(self, qdict: QDict) -> None:
69   - '''Add k:v pairs from default dict d for nonexistent keys'''
  73 + """Add k:v pairs from default dict d for nonexistent keys"""
70 74 for k, val in qdict.items():
71 75 self.setdefault(k, val)
72 76  
73 77  
74 78 # ============================================================================
75 79 class QuestionRadio(Question):
76   - '''An instance of QuestionRadio will always have the keys:
77   - type (str)
78   - text (str)
79   - options (list of strings)
80   - correct (list of floats)
81   - discount (bool, default=True)
82   - answer (None or an actual answer)
83   - shuffle (bool, default=True)
84   - choose (int) # only used if shuffle=True
85   - '''
  80 + """An instance of QuestionRadio will always have the keys:
  81 + type (str)
  82 + text (str)
  83 + options (list of strings)
  84 + correct (list of floats)
  85 + discount (bool, default=True)
  86 + answer (None or an actual answer)
  87 + shuffle (bool, default=True)
  88 + choose (int) # only used if shuffle=True
  89 + """
86 90  
87 91 # ------------------------------------------------------------------------
88 92 def gen(self) -> None:
89   - '''
  93 + """
90 94 Sets defaults, performs checks and generates the actual question
91 95 by modifying the options and correct values
92   - '''
  96 + """
93 97 super().gen()
94 98 try:
95   - nopts = len(self['options'])
  99 + nopts = len(self["options"])
96 100 except KeyError as exc:
97 101 msg = f'Missing `options`. In question "{self["ref"]}"'
98 102 logger.error(msg)
... ... @@ -102,121 +106,125 @@ class QuestionRadio(Question):
102 106 logger.error(msg)
103 107 raise QuestionException(msg) from exc
104 108  
105   - self.set_defaults(QDict({
106   - 'text': '',
107   - 'correct': 0,
108   - 'shuffle': True,
109   - 'discount': True,
110   - 'max_tries': (nopts + 3) // 4 # 1 try for each 4 options
111   - }))
  109 + self.set_defaults(
  110 + QDict(
  111 + {
  112 + "text": "",
  113 + "correct": 0,
  114 + "shuffle": True,
  115 + "discount": True,
  116 + "max_tries": (nopts + 3) // 4, # 1 try for each 4 options
  117 + }
  118 + )
  119 + )
112 120  
113 121 # check correct bounds and convert int to list,
114 122 # e.g. correct: 2 --> correct: [0,0,1,0,0]
115   - if isinstance(self['correct'], int):
116   - if not 0 <= self['correct'] < nopts:
  123 + if isinstance(self["correct"], int):
  124 + if not 0 <= self["correct"] < nopts:
117 125 msg = f'"{self["ref"]}": correct out of range 0..{nopts-1}'
118 126 logger.error(msg)
119 127 raise QuestionException(msg)
120 128  
121   - self['correct'] = [1.0 if x == self['correct'] else 0.0
122   - for x in range(nopts)]
  129 + self["correct"] = [
  130 + 1.0 if x == self["correct"] else 0.0 for x in range(nopts)
  131 + ]
123 132  
124   - elif isinstance(self['correct'], list):
  133 + elif isinstance(self["correct"], list):
125 134 # must match number of options
126   - if len(self['correct']) != nopts:
  135 + if len(self["correct"]) != nopts:
127 136 msg = f'"{self["ref"]}": number of options/correct mismatch'
128 137 logger.error(msg)
129 138 raise QuestionException(msg)
130 139  
131 140 # make sure is a list of floats
132 141 try:
133   - self['correct'] = [float(x) for x in self['correct']]
  142 + self["correct"] = [float(x) for x in self["correct"]]
134 143 except (ValueError, TypeError) as exc:
135 144 msg = f'"{self["ref"]}": correct must contain floats or bools'
136 145 logger.error(msg)
137 146 raise QuestionException(msg) from exc
138 147  
139 148 # check grade boundaries
140   - if self['discount'] and not all(0.0 <= x <= 1.0
141   - for x in self['correct']):
  149 + if self["discount"] and not all(0.0 <= x <= 1.0 for x in self["correct"]):
142 150 msg = f'"{self["ref"]}": correct must be in [0.0, 1.0]'
143 151 logger.error(msg)
144 152 raise QuestionException(msg)
145 153  
146 154 # at least one correct option
147   - if all(x < 1.0 for x in self['correct']):
  155 + if all(x < 1.0 for x in self["correct"]):
148 156 msg = f'"{self["ref"]}": has no correct options'
149 157 logger.error(msg)
150 158 raise QuestionException(msg)
151 159  
152 160 # If shuffle==false, all options are shown as defined
153 161 # otherwise, select 1 correct and choose a few wrong ones
154   - if self['shuffle']:
  162 + if self["shuffle"]:
155 163 # lists with indices of right and wrong options
156   - right = [i for i in range(nopts) if self['correct'][i] >= 1]
157   - wrong = [i for i in range(nopts) if self['correct'][i] < 1]
  164 + right = [i for i in range(nopts) if self["correct"][i] >= 1]
  165 + wrong = [i for i in range(nopts) if self["correct"][i] < 1]
158 166  
159   - self.set_defaults(QDict({'choose': 1+len(wrong)}))
  167 + self.set_defaults(QDict({"choose": 1 + len(wrong)}))
160 168  
161 169 # try to choose 1 correct option
162 170 if right:
163 171 sel = random.choice(right)
164   - options = [self['options'][sel]]
165   - correct = [self['correct'][sel]]
  172 + options = [self["options"][sel]]
  173 + correct = [self["correct"][sel]]
166 174 else:
167 175 options = []
168 176 correct = []
169 177  
170 178 # choose remaining wrong options
171   - nwrong = self['choose'] - len(correct)
  179 + nwrong = self["choose"] - len(correct)
172 180 wrongsample = random.sample(wrong, k=nwrong)
173   - options += [self['options'][i] for i in wrongsample]
174   - correct += [self['correct'][i] for i in wrongsample]
  181 + options += [self["options"][i] for i in wrongsample]
  182 + correct += [self["correct"][i] for i in wrongsample]
175 183  
176 184 # final shuffle of the options
177   - perm = random.sample(range(self['choose']), k=self['choose'])
178   - self['options'] = [str(options[i]) for i in perm]
179   - self['correct'] = [correct[i] for i in perm]
  185 + perm = random.sample(range(self["choose"]), k=self["choose"])
  186 + self["options"] = [str(options[i]) for i in perm]
  187 + self["correct"] = [correct[i] for i in perm]
180 188  
181 189 # ------------------------------------------------------------------------
182 190 def correct(self) -> None:
183   - '''
  191 + """
184 192 Correct `answer` and set `grade`.
185 193 Can assign negative grades for wrong answers
186   - '''
  194 + """
187 195 super().correct()
188 196  
189   - if self['answer'] is not None:
190   - grade = self['correct'][int(self['answer'])] # grade of the answer
191   - nopts = len(self['options'])
192   - grade_aver = sum(self['correct']) / nopts # expected value
  197 + if self["answer"] is not None:
  198 + grade = self["correct"][int(self["answer"])] # grade of the answer
  199 + nopts = len(self["options"])
  200 + grade_aver = sum(self["correct"]) / nopts # expected value
193 201  
194 202 # note: there are no numerical errors when summing 1.0s so the
195 203 # x_aver can be exactly 1.0 if all options are right
196   - if self['discount'] and grade_aver != 1.0:
  204 + if self["discount"] and grade_aver != 1.0:
197 205 grade = (grade - grade_aver) / (1.0 - grade_aver)
198   - self['grade'] = grade
  206 + self["grade"] = grade
199 207  
200 208  
201 209 # ============================================================================
202 210 class QuestionCheckbox(Question):
203   - '''An instance of QuestionCheckbox will always have the keys:
204   - type (str)
205   - text (str)
206   - options (list of strings)
207   - shuffle (bool, default True)
208   - correct (list of floats)
209   - discount (bool, default True)
210   - choose (int)
211   - answer (None or an actual answer)
212   - '''
  211 + """An instance of QuestionCheckbox will always have the keys:
  212 + type (str)
  213 + text (str)
  214 + options (list of strings)
  215 + shuffle (bool, default True)
  216 + correct (list of floats)
  217 + discount (bool, default True)
  218 + choose (int)
  219 + answer (None or an actual answer)
  220 + """
213 221  
214 222 # ------------------------------------------------------------------------
215 223 def gen(self) -> None:
216 224 super().gen()
217 225  
218 226 try:
219   - nopts = len(self['options'])
  227 + nopts = len(self["options"])
220 228 except KeyError as exc:
221 229 msg = f'Missing `options`. In question "{self["ref"]}"'
222 230 logger.error(msg)
... ... @@ -227,50 +235,56 @@ class QuestionCheckbox(Question):
227 235 raise QuestionException(msg) from exc
228 236  
229 237 # set defaults if missing
230   - self.set_defaults(QDict({
231   - 'text': '',
232   - 'correct': [1.0] * nopts, # Using 0.0 breaks (right, wrong)
233   - 'shuffle': True,
234   - 'discount': True,
235   - 'choose': nopts, # number of options
236   - 'max_tries': max(1, min(nopts - 1, 3))
237   - }))
  238 + self.set_defaults(
  239 + QDict(
  240 + {
  241 + "text": "",
  242 + "correct": [1.0] * nopts, # Using 0.0 breaks (right, wrong)
  243 + "shuffle": True,
  244 + "discount": True,
  245 + "choose": nopts, # number of options
  246 + "max_tries": max(1, min(nopts - 1, 3)),
  247 + }
  248 + )
  249 + )
238 250  
239 251 # must be a list of numbers
240   - if not isinstance(self['correct'], list):
241   - msg = 'Correct must be a list of numbers or booleans'
  252 + if not isinstance(self["correct"], list):
  253 + msg = "Correct must be a list of numbers or booleans"
242 254 logger.error(msg)
243 255 raise QuestionException(msg)
244 256  
245 257 # must match number of options
246   - if len(self['correct']) != nopts:
247   - msg = (f'{nopts} options vs {len(self["correct"])} correct. '
248   - f'In question "{self["ref"]}"')
  258 + if len(self["correct"]) != nopts:
  259 + msg = (
  260 + f'{nopts} options vs {len(self["correct"])} correct. '
  261 + f'In question "{self["ref"]}"'
  262 + )
249 263 logger.error(msg)
250 264 raise QuestionException(msg)
251 265  
252 266 # make sure is a list of floats
253 267 try:
254   - self['correct'] = [float(x) for x in self['correct']]
  268 + self["correct"] = [float(x) for x in self["correct"]]
255 269 except (ValueError, TypeError) as exc:
256   - msg = ('`correct` must be list of numbers or booleans.'
257   - f'In "{self["ref"]}"')
  270 + msg = "`correct` must be list of numbers or booleans." f'In "{self["ref"]}"'
258 271 logger.error(msg)
259 272 raise QuestionException(msg) from exc
260 273  
261 274 # check grade boundaries
262   - if self['discount'] and not all(0.0 <= x <= 1.0
263   - for x in self['correct']):
264   - msg = ('values in the `correct` field of checkboxes must be in '
265   - 'the [0.0, 1.0] interval. '
266   - f'Please fix "{self["ref"]}" in "{self["path"]}"')
  275 + if self["discount"] and not all(0.0 <= x <= 1.0 for x in self["correct"]):
  276 + msg = (
  277 + "values in the `correct` field of checkboxes must be in "
  278 + "the [0.0, 1.0] interval. "
  279 + f'Please fix "{self["ref"]}" in "{self["path"]}"'
  280 + )
267 281 logger.error(msg)
268 282 raise QuestionException(msg)
269 283  
270 284 # if an option is a list of (right, wrong), pick one
271 285 options = []
272 286 correct = []
273   - for option, corr in zip(self['options'], self['correct']):
  287 + for option, corr in zip(self["options"], self["correct"]):
274 288 if isinstance(option, list):
275 289 sel = random.randint(0, 1)
276 290 option = option[sel]
... ... @@ -281,252 +295,288 @@ class QuestionCheckbox(Question):
281 295  
282 296 # generate random permutation, e.g. [2,1,4,0,3]
283 297 # and apply to `options` and `correct`
284   - if self['shuffle']:
285   - perm = random.sample(range(nopts), k=self['choose'])
286   - self['options'] = [options[i] for i in perm]
287   - self['correct'] = [correct[i] for i in perm]
  298 + if self["shuffle"]:
  299 + perm = random.sample(range(nopts), k=self["choose"])
  300 + self["options"] = [options[i] for i in perm]
  301 + self["correct"] = [correct[i] for i in perm]
288 302 else:
289   - self['options'] = options[:self['choose']]
290   - self['correct'] = correct[:self['choose']]
  303 + self["options"] = options[: self["choose"]]
  304 + self["correct"] = correct[: self["choose"]]
291 305  
292 306 # ------------------------------------------------------------------------
293 307 # can return negative values for wrong answers
294 308 def correct(self) -> None:
295 309 super().correct()
296 310  
297   - if self['answer'] is not None:
  311 + if self["answer"] is not None:
298 312 grade = 0.0
299   - if self['discount']:
300   - sum_abs = sum(abs(2*p-1) for p in self['correct'])
301   - for i, pts in enumerate(self['correct']):
302   - grade += 2*pts-1 if str(i) in self['answer'] else 1-2*pts
  313 + if self["discount"]:
  314 + sum_abs = sum(abs(2 * p - 1) for p in self["correct"])
  315 + for i, pts in enumerate(self["correct"]):
  316 + grade += 2 * pts - 1 if str(i) in self["answer"] else 1 - 2 * pts
303 317 else:
304   - sum_abs = sum(abs(p) for p in self['correct'])
305   - for i, pts in enumerate(self['correct']):
306   - grade += pts if str(i) in self['answer'] else 0.0
  318 + sum_abs = sum(abs(p) for p in self["correct"])
  319 + for i, pts in enumerate(self["correct"]):
  320 + grade += pts if str(i) in self["answer"] else 0.0
307 321  
308 322 try:
309   - self['grade'] = grade / sum_abs
  323 + self["grade"] = grade / sum_abs
310 324 except ZeroDivisionError:
311   - self['grade'] = 1.0 # limit p->0
  325 + self["grade"] = 1.0 # limit p->0
312 326  
313 327  
314 328 # ============================================================================
315 329 class QuestionText(Question):
316   - '''An instance of QuestionText will always have the keys:
317   - type (str)
318   - text (str)
319   - correct (list of str)
320   - answer (None or an actual answer)
321   - '''
  330 + """An instance of QuestionText will always have the keys:
  331 + type (str)
  332 + text (str)
  333 + correct (list of str)
  334 + answer (None or an actual answer)
  335 + """
322 336  
323 337 # ------------------------------------------------------------------------
324 338 def gen(self) -> None:
325 339 super().gen()
326   - self.set_defaults(QDict({
327   - 'text': '',
328   - 'correct': [], # no correct answers, always wrong
329   - 'transform': [], # transformations applied to the answer, in order
330   - }))
  340 + self.set_defaults(
  341 + QDict(
  342 + {
  343 + "text": "",
  344 + "correct": [], # no correct answers, always wrong
  345 + "transform": [], # transformations applied to the answer, in order
  346 + }
  347 + )
  348 + )
331 349  
332 350 # make sure its always a list of possible correct answers
333   - if not isinstance(self['correct'], list):
334   - self['correct'] = [str(self['correct'])]
  351 + if not isinstance(self["correct"], list):
  352 + self["correct"] = [str(self["correct"])]
335 353 else:
336 354 # make sure all elements of the list are strings
337   - self['correct'] = [str(a) for a in self['correct']]
338   -
339   - for transform in self['transform']:
340   - if transform not in ('remove_space', 'trim', 'normalize_space',
341   - 'lower', 'upper'):
342   - msg = (f'Unknown transform "{transform}" in "{self["ref"]}"')
  355 + self["correct"] = [str(a) for a in self["correct"]]
  356 +
  357 + for transform in self["transform"]:
  358 + if transform not in (
  359 + "remove_space",
  360 + "trim",
  361 + "normalize_space",
  362 + "lower",
  363 + "upper",
  364 + ):
  365 + msg = f'Unknown transform "{transform}" in "{self["ref"]}"'
343 366 raise QuestionException(msg)
344 367  
345 368 # check if answers are invariant with respect to the transforms
346   - if any(c != self.transform(c) for c in self['correct']):
347   - logger.warning('in "%s", correct answers are not invariant wrt '
348   - 'transformations => never correct', self["ref"])
  369 + if any(c != self.transform(c) for c in self["correct"]):
  370 + logger.warning(
  371 + 'in "%s", correct answers are not invariant wrt '
  372 + "transformations => never correct",
  373 + self["ref"],
  374 + )
349 375  
350 376 # ------------------------------------------------------------------------
351 377 def transform(self, ans):
352   - '''apply optional filters to the answer'''
  378 + """apply optional filters to the answer"""
353 379  
354 380 # apply transformations in sequence
355   - for transform in self['transform']:
356   - if transform == 'remove_space': # removes all spaces
357   - ans = ans.replace(' ', '')
358   - elif transform == 'trim': # removes spaces around
  381 + for transform in self["transform"]:
  382 + if transform == "remove_space": # removes all spaces
  383 + ans = ans.replace(" ", "")
  384 + elif transform == "trim": # removes spaces around
359 385 ans = ans.strip()
360   - elif transform == 'normalize_space': # replaces many spaces by one
361   - ans = re.sub(r'\s+', ' ', ans.strip())
362   - elif transform == 'lower': # convert to lowercase
  386 + elif transform == "normalize_space": # replaces many spaces by one
  387 + ans = re.sub(r"\s+", " ", ans.strip())
  388 + elif transform == "lower": # convert to lowercase
363 389 ans = ans.lower()
364   - elif transform == 'upper': # convert to uppercase
  390 + elif transform == "upper": # convert to uppercase
365 391 ans = ans.upper()
366 392 else:
367   - logger.warning('in "%s", unknown transform "%s"',
368   - self["ref"], transform)
  393 + logger.warning(
  394 + 'in "%s", unknown transform "%s"', self["ref"], transform
  395 + )
369 396 return ans
370 397  
371 398 # ------------------------------------------------------------------------
372 399 def correct(self) -> None:
373 400 super().correct()
374 401  
375   - if self['answer'] is not None:
376   - answer = self.transform(self['answer'])
377   - self['grade'] = 1.0 if answer in self['correct'] else 0.0
  402 + if self["answer"] is not None:
  403 + answer = self.transform(self["answer"])
  404 + self["grade"] = 1.0 if answer in self["correct"] else 0.0
378 405  
379 406  
380 407 # ============================================================================
381 408 class QuestionTextRegex(Question):
382   - '''An instance of QuestionTextRegex will always have the keys:
383   - type (str)
384   - text (str)
385   - correct (str or list[str])
386   - answer (None or an actual answer)
  409 + """An instance of QuestionTextRegex will always have the keys:
  410 + type (str)
  411 + text (str)
  412 + correct (str or list[str])
  413 + answer (None or an actual answer)
387 414  
388   - The correct strings are python standard regular expressions.
389   - Grade is 1.0 when the answer matches any of the regex in the list.
390   - '''
  415 + The correct strings are python standard regular expressions.
  416 + Grade is 1.0 when the answer matches any of the regex in the list.
  417 + """
391 418  
392 419 # ------------------------------------------------------------------------
393 420 def gen(self) -> None:
394 421 super().gen()
395 422  
396   - self.set_defaults(QDict({
397   - 'text': '',
398   - 'correct': ['$.^'], # will always return false
399   - }))
  423 + self.set_defaults(
  424 + QDict(
  425 + {
  426 + "text": "",
  427 + "correct": ["$.^"], # will always return false
  428 + }
  429 + )
  430 + )
400 431  
401 432 # make sure its always a list of regular expressions
402   - if not isinstance(self['correct'], list):
403   - self['correct'] = [self['correct']]
  433 + if not isinstance(self["correct"], list):
  434 + self["correct"] = [self["correct"]]
404 435  
405 436 # ------------------------------------------------------------------------
406 437 def correct(self) -> None:
407 438 super().correct()
408   - if self['answer'] is not None:
409   - for regex in self['correct']:
  439 + if self["answer"] is not None:
  440 + for regex in self["correct"]:
410 441 try:
411   - if re.fullmatch(regex, self['answer']):
412   - self['grade'] = 1.0
  442 + if re.fullmatch(regex, self["answer"]):
  443 + self["grade"] = 1.0
413 444 return
414 445 except TypeError:
415   - logger.error('While matching regex "%s" with answer "%s".',
416   - regex, self['answer'])
417   - self['grade'] = 0.0
  446 + logger.error(
  447 + 'While matching regex "%s" with answer "%s".',
  448 + regex,
  449 + self["answer"],
  450 + )
  451 + self["grade"] = 0.0
  452 +
418 453  
419 454 # ============================================================================
420 455 class QuestionNumericInterval(Question):
421   - '''An instance of QuestionTextNumeric will always have the keys:
  456 + """An instance of QuestionTextNumeric will always have the keys:
422 457 type (str)
423 458 text (str)
424 459 correct (list [lower bound, upper bound])
425 460 answer (None or an actual answer)
426 461 An answer is correct if it's in the closed interval.
427   - '''
  462 + """
428 463  
429 464 # ------------------------------------------------------------------------
430 465 def gen(self) -> None:
431 466 super().gen()
432 467  
433   - self.set_defaults(QDict({
434   - 'text': '',
435   - 'correct': [1.0, -1.0], # will always return false
436   - }))
  468 + self.set_defaults(
  469 + QDict(
  470 + {
  471 + "text": "",
  472 + "correct": [1.0, -1.0], # will always return false
  473 + }
  474 + )
  475 + )
437 476  
438 477 # if only one number n is given, make an interval [n,n]
439   - if isinstance(self['correct'], (int, float)):
440   - self['correct'] = [float(self['correct']), float(self['correct'])]
  478 + if isinstance(self["correct"], (int, float)):
  479 + self["correct"] = [float(self["correct"]), float(self["correct"])]
441 480  
442 481 # make sure its a list of two numbers
443   - elif isinstance(self['correct'], list):
444   - if len(self['correct']) != 2:
445   - msg = (f'Numeric interval must be a list with two numbers, in '
446   - f'{self["ref"]}')
  482 + elif isinstance(self["correct"], list):
  483 + if len(self["correct"]) != 2:
  484 + msg = (
  485 + f"Numeric interval must be a list with two numbers, in "
  486 + f'{self["ref"]}'
  487 + )
447 488 logger.error(msg)
448 489 raise QuestionException(msg)
449 490  
450 491 try:
451   - self['correct'] = [float(n) for n in self['correct']]
  492 + self["correct"] = [float(n) for n in self["correct"]]
452 493 except Exception as exc:
453   - msg = (f'Numeric interval must be a list with two numbers, in '
454   - f'{self["ref"]}')
  494 + msg = (
  495 + f"Numeric interval must be a list with two numbers, in "
  496 + f'{self["ref"]}'
  497 + )
455 498 logger.error(msg)
456 499 raise QuestionException(msg) from exc
457 500  
458 501 # invalid
459 502 else:
460   - msg = (f'Numeric interval must be a list with two numbers, in '
461   - f'{self["ref"]}')
  503 + msg = (
  504 + f"Numeric interval must be a list with two numbers, in "
  505 + f'{self["ref"]}'
  506 + )
462 507 logger.error(msg)
463 508 raise QuestionException(msg)
464 509  
465 510 # ------------------------------------------------------------------------
466 511 def correct(self) -> None:
467 512 super().correct()
468   - if self['answer'] is not None:
469   - lower, upper = self['correct']
  513 + if self["answer"] is not None:
  514 + lower, upper = self["correct"]
470 515  
471   - try: # replace , by . and convert to float
472   - answer = float(self['answer'].replace(',', '.', 1))
  516 + try: # replace , by . and convert to float
  517 + answer = float(self["answer"].replace(",", ".", 1))
473 518 except ValueError:
474   - self['comments'] = ('A resposta tem de ser numérica, '
475   - 'por exemplo `12.345`.')
476   - self['grade'] = 0.0
  519 + self["comments"] = (
  520 + "A resposta tem de ser numérica, " "por exemplo `12.345`."
  521 + )
  522 + self["grade"] = 0.0
477 523 else:
478   - self['grade'] = 1.0 if lower <= answer <= upper else 0.0
  524 + self["grade"] = 1.0 if lower <= answer <= upper else 0.0
479 525  
480 526  
481 527 # ============================================================================
482 528 class QuestionTextArea(Question):
483   - '''An instance of QuestionTextArea will always have the keys:
484   - type (str)
485   - text (str)
486   - correct (str with script to run)
487   - answer (None or an actual answer)
488   - '''
  529 + """An instance of QuestionTextArea will always have the keys:
  530 + type (str)
  531 + text (str)
  532 + correct (str with script to run)
  533 + answer (None or an actual answer)
  534 + """
489 535  
490 536 # ------------------------------------------------------------------------
491 537 def gen(self) -> None:
492 538 super().gen()
493 539  
494   - self.set_defaults(QDict({
495   - 'text': '',
496   - 'timeout': 5, # seconds
497   - 'correct': '', # trying to execute this will fail => grade 0.0
498   - 'args': []
499   - }))
  540 + self.set_defaults(
  541 + QDict(
  542 + {
  543 + "text": "",
  544 + "timeout": 5, # seconds
  545 + "correct": "", # trying to execute this will fail => grade 0.0
  546 + "args": [],
  547 + }
  548 + )
  549 + )
500 550  
501   - self['correct'] = os.path.join(self['path'], self['correct'])
  551 + self["correct"] = os.path.join(self["path"], self["correct"])
502 552  
503 553 # ------------------------------------------------------------------------
504 554 def correct(self) -> None:
505 555 super().correct()
506 556  
507   - if self['answer'] is not None: # correct answer and parse yaml ouput
  557 + if self["answer"] is not None: # correct answer and parse yaml ouput
508 558 out = run_script(
509   - script=self['correct'],
510   - args=self['args'],
511   - stdin=self['answer'],
512   - timeout=self['timeout']
513   - )
  559 + script=self["correct"],
  560 + args=self["args"],
  561 + stdin=self["answer"],
  562 + timeout=self["timeout"],
  563 + )
514 564  
515 565 if out is None:
516 566 logger.warning('No grade after running "%s".', self["correct"])
517   - self['comments'] = 'O programa de correcção abortou...'
518   - self['grade'] = 0.0
  567 + self["comments"] = "O programa de correcção abortou..."
  568 + self["grade"] = 0.0
519 569 elif isinstance(out, dict):
520   - self['comments'] = out.get('comments', '')
  570 + self["comments"] = out.get("comments", "")
521 571 try:
522   - self['grade'] = float(out['grade'])
  572 + self["grade"] = float(out["grade"])
523 573 except ValueError:
524 574 logger.error('Output error in "%s".', self["correct"])
525 575 except KeyError:
526 576 logger.error('No grade in "%s".', self["correct"])
527 577 else:
528 578 try:
529   - self['grade'] = float(out)
  579 + self["grade"] = float(out)
530 580 except (TypeError, ValueError):
531 581 logger.error('Invalid grade in "%s".', self["correct"])
532 582  
... ... @@ -534,92 +584,96 @@ class QuestionTextArea(Question):
534 584 async def correct_async(self) -> None:
535 585 super().correct()
536 586  
537   - if self['answer'] is not None: # correct answer and parse yaml ouput
  587 + if self["answer"] is not None: # correct answer and parse yaml ouput
538 588 out = await run_script_async(
539   - script=self['correct'],
540   - args=self['args'],
541   - stdin=self['answer'],
542   - timeout=self['timeout']
543   - )
  589 + script=self["correct"],
  590 + args=self["args"],
  591 + stdin=self["answer"],
  592 + timeout=self["timeout"],
  593 + )
544 594  
545 595 if out is None:
546 596 logger.warning('No grade after running "%s".', self["correct"])
547   - self['comments'] = 'O programa de correcção abortou...'
548   - self['grade'] = 0.0
  597 + self["comments"] = "O programa de correcção abortou..."
  598 + self["grade"] = 0.0
549 599 elif isinstance(out, dict):
550   - self['comments'] = out.get('comments', '')
  600 + self["comments"] = out.get("comments", "")
551 601 try:
552   - self['grade'] = float(out['grade'])
  602 + self["grade"] = float(out["grade"])
553 603 except ValueError:
554 604 logger.error('Output error in "%s".', self["correct"])
555 605 except KeyError:
556 606 logger.error('No grade in "%s".', self["correct"])
557 607 else:
558 608 try:
559   - self['grade'] = float(out)
  609 + self["grade"] = float(out)
560 610 except (TypeError, ValueError):
561 611 logger.error('Invalid grade in "%s".', self["correct"])
562 612  
563 613  
564 614 # ============================================================================
565 615 class QuestionInformation(Question):
566   - '''
  616 + """
567 617 Not really a question, just an information panel.
568 618 The correction is always right.
569   - '''
  619 + """
570 620  
571 621 # ------------------------------------------------------------------------
572 622 def gen(self) -> None:
573 623 super().gen()
574   - self.set_defaults(QDict({
575   - 'text': '',
576   - }))
  624 + self.set_defaults(
  625 + QDict(
  626 + {
  627 + "text": "",
  628 + }
  629 + )
  630 + )
577 631  
578 632 # ------------------------------------------------------------------------
579 633 def correct(self) -> None:
580 634 super().correct()
581   - self['grade'] = 1.0 # always "correct" but points should be zero!
  635 + self["grade"] = 1.0 # always "correct" but points should be zero!
582 636  
583 637  
584 638 # ============================================================================
585 639 def question_from(qdict: QDict) -> Question:
586   - '''
  640 + """
587 641 Converts a question specified in a dict into an instance of Question()
588   - '''
  642 + """
589 643 types = {
590   - 'radio': QuestionRadio,
591   - 'checkbox': QuestionCheckbox,
592   - 'text': QuestionText,
593   - 'text-regex': QuestionTextRegex,
594   - 'numeric-interval': QuestionNumericInterval,
595   - 'textarea': QuestionTextArea,
  644 + "radio": QuestionRadio,
  645 + "checkbox": QuestionCheckbox,
  646 + "text": QuestionText,
  647 + "text-regex": QuestionTextRegex,
  648 + "numeric-interval": QuestionNumericInterval,
  649 + "textarea": QuestionTextArea,
596 650 # -- informative panels --
597   - 'information': QuestionInformation,
598   - 'success': QuestionInformation,
599   - 'warning': QuestionInformation,
600   - 'alert': QuestionInformation,
601   - }
  651 + "information": QuestionInformation,
  652 + "success": QuestionInformation,
  653 + "warning": QuestionInformation,
  654 + "alert": QuestionInformation,
  655 + }
602 656  
603 657 # Get class for this question type
604 658 try:
605   - qclass = types[qdict['type']]
  659 + qclass = types[qdict["type"]]
606 660 except KeyError:
607   - logger.error('Invalid type "%s" in "%s"', qdict['type'], qdict['ref'])
  661 + logger.error('Invalid type "%s" in "%s"', qdict["type"], qdict["ref"])
608 662 raise
609 663  
610 664 # Create an instance of Question() of appropriate type
611 665 try:
612 666 qinstance = qclass(qdict.copy())
613 667 except QuestionException:
614   - logger.error('Generating "%s" in %s', qdict['ref'], qdict['filename'])
  668 + logger.error('Generating "%s" in %s', qdict["ref"], qdict["filename"])
615 669 raise
616 670  
617 671 return qinstance
618 672  
619 673  
620 674 # ============================================================================
621   -class QFactory():
622   - '''
  675 +class QFactory:
  676 + """
623 677 QFactory is a class that can generate question instances, e.g. by shuffling
624 678 options, running a script to generate the question, etc.
625 679  
... ... @@ -644,35 +698,35 @@ class QFactory():
644 698 question.set_answer(42) # set answer
645 699 question.correct() # correct answer
646 700 grade = question['grade'] # get grade
647   - '''
  701 + """
648 702  
649 703 def __init__(self, qdict: QDict = QDict({})) -> None:
650 704 self.qdict = qdict
651 705  
652 706 # ------------------------------------------------------------------------
653 707 async def gen_async(self) -> Question:
654   - '''
  708 + """
655 709 generates a question instance of QuestionRadio, QuestionCheckbox, ...,
656 710 which is a descendent of base class Question.
657   - '''
  711 + """
658 712  
659   - logger.debug('generating %s...', self.qdict["ref"])
  713 + logger.debug("generating %s...", self.qdict["ref"])
660 714 # Shallow copy so that script generated questions will not replace
661 715 # the original generators
662 716 qdict = QDict(self.qdict.copy())
663   - qdict['qid'] = str(uuid.uuid4()) # unique for each question
  717 + qdict["qid"] = str(uuid.uuid4()) # unique for each question
664 718  
665 719 # If question is of generator type, an external program will be run
666 720 # which will print a valid question in yaml format to stdout. This
667 721 # output is then yaml parsed into a dictionary `q`.
668   - if qdict['type'] == 'generator':
669   - logger.debug(' \\_ Running "%s"', qdict['script'])
670   - qdict.setdefault('args', [])
671   - qdict.setdefault('stdin', '')
672   - script = os.path.join(qdict['path'], qdict['script'])
673   - out = await run_script_async(script=script,
674   - args=qdict['args'],
675   - stdin=qdict['stdin'])
  722 + if qdict["type"] == "generator":
  723 + logger.debug(' \\_ Running "%s"', qdict["script"])
  724 + qdict.setdefault("args", [])
  725 + qdict.setdefault("stdin", "")
  726 + script = os.path.join(qdict["path"], qdict["script"])
  727 + out = await run_script_async(
  728 + script=script, args=qdict["args"], stdin=qdict["stdin"]
  729 + )
676 730 qdict.update(out)
677 731  
678 732 question = question_from(qdict) # returns a Question instance
... ...
perguntations/serve.py
1   -'''
  1 +
  2 +"""
2 3 Handles the web, http & html part of the application interface.
3 4 Uses the tornadoweb framework.
4   -'''
  5 +"""
5 6  
6 7 # python standard library
7 8 import asyncio
... ... @@ -31,29 +32,30 @@ logger = logging.getLogger(__name__)
31 32  
32 33 # ----------------------------------------------------------------------------
33 34 class WebApplication(tornado.web.Application):
34   - '''
  35 + """
35 36 Web Application. Routes to handler classes.
36   - '''
  37 + """
  38 +
37 39 def __init__(self, testapp, debug=False):
38 40 handlers = [
39   - (r'/login', LoginHandler),
40   - (r'/logout', LogoutHandler),
41   - (r'/review', ReviewHandler),
42   - (r'/admin', AdminHandler),
43   - (r'/file', FileHandler),
44   - (r'/adminwebservice', AdminWebservice),
45   - (r'/studentwebservice', StudentWebservice),
46   - (r'/', RootHandler),
  41 + (r"/login", LoginHandler),
  42 + (r"/logout", LogoutHandler),
  43 + (r"/review", ReviewHandler),
  44 + (r"/admin", AdminHandler),
  45 + (r"/file", FileHandler),
  46 + (r"/adminwebservice", AdminWebservice),
  47 + (r"/studentwebservice", StudentWebservice),
  48 + (r"/", RootHandler),
47 49 ]
48 50  
49 51 settings = {
50   - 'template_path': path.join(path.dirname(__file__), 'templates'),
51   - 'static_path': path.join(path.dirname(__file__), 'static'),
52   - 'static_url_prefix': '/static/',
53   - 'xsrf_cookies': True,
54   - 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),
55   - 'login_url': '/login',
56   - 'debug': debug,
  52 + "template_path": path.join(path.dirname(__file__), "templates"),
  53 + "static_path": path.join(path.dirname(__file__), "static"),
  54 + "static_url_prefix": "/static/",
  55 + "xsrf_cookies": True,
  56 + "cookie_secret": base64.b64encode(uuid.uuid4().bytes),
  57 + "login_url": "/login",
  58 + "debug": debug,
57 59 }
58 60 super().__init__(handlers, **settings)
59 61 self.testapp = testapp
... ... @@ -61,32 +63,34 @@ class WebApplication(tornado.web.Application):
61 63  
62 64 # ----------------------------------------------------------------------------
63 65 def admin_only(func):
64   - '''
  66 + """
65 67 Decorator to restrict access to the administrator:
66 68  
67 69 @admin_only
68 70 def get(self):
69   - '''
  71 + """
  72 +
70 73 @functools.wraps(func)
71 74 async def wrapper(self, *args, **kwargs):
72   - if self.current_user != '0':
  75 + if self.current_user != "0":
73 76 raise tornado.web.HTTPError(403) # forbidden
74 77 await func(self, *args, **kwargs)
  78 +
75 79 return wrapper
76 80  
77 81  
78 82 # ----------------------------------------------------------------------------
79 83 # pylint: disable=abstract-method
80 84 class BaseHandler(tornado.web.RequestHandler):
81   - '''
  85 + """
82 86 Handlers should inherit this one instead of tornado.web.RequestHandler.
83 87 It automatically gets the user cookie, which is required to identify the
84 88 user in most handlers.
85   - '''
  89 + """
86 90  
87 91 @property
88 92 def testapp(self):
89   - '''simplifies access to the application a little bit'''
  93 + """simplifies access to the application a little bit"""
90 94 return self.application.testapp
91 95  
92 96 # @property
... ... @@ -95,62 +99,62 @@ class BaseHandler(tornado.web.RequestHandler):
95 99 # return self.application.testapp.debug
96 100  
97 101 def get_current_user(self):
98   - '''
  102 + """
99 103 Since HTTP is stateless, a cookie is used to identify the user.
100 104 This function returns the cookie for the current user.
101   - '''
102   - cookie = self.get_signed_cookie('perguntations_user')
  105 + """
  106 + cookie = self.get_secure_cookie("perguntations_user")
103 107 if cookie:
104   - return cookie.decode('utf-8')
  108 + return cookie.decode("utf-8")
105 109 return None
106 110  
107 111  
108 112 # ----------------------------------------------------------------------------
109 113 # pylint: disable=abstract-method
110 114 class LoginHandler(BaseHandler):
111   - '''Handles /login'''
  115 + """Handles /login"""
112 116  
113   - _prefix = re.compile(r'[a-z]')
  117 + _prefix = re.compile(r"[a-z]")
114 118 _error_msg = {
115   - 'wrong_password': 'Senha errada',
116   - 'not_allowed': 'Não está autorizado a fazer o teste',
117   - 'nonexistent': 'Número de aluno inválido'
  119 + "wrong_password": "Senha errada",
  120 + "not_allowed": "Não está autorizado a fazer o teste",
  121 + "nonexistent": "Número de aluno inválido",
118 122 }
119 123  
120 124 def get(self):
121   - '''Render login page.'''
122   - self.render('login.html', error='')
  125 + """Render login page."""
  126 + self.render("login.html", error="")
123 127  
124 128 async def post(self):
125   - '''Authenticates student and login.'''
126   - uid = self.get_body_argument('uid')
127   - password = self.get_body_argument('pw')
  129 + """Authenticates student and login."""
  130 + uid = self.get_body_argument("uid")
  131 + password = self.get_body_argument("pw")
128 132 headers = {
129   - 'remote_ip': self.request.remote_ip,
130   - 'user_agent': self.request.headers.get('User-Agent')
  133 + "remote_ip": self.request.remote_ip,
  134 + "user_agent": self.request.headers.get("User-Agent"),
131 135 }
132 136  
133 137 error = await self.testapp.login(uid, password, headers)
134 138  
135 139 if error is not None:
136 140 await asyncio.sleep(3) # delay to avoid spamming the server...
137   - self.render('login.html', error=self._error_msg[error])
  141 + self.render("login.html", error=self._error_msg[error])
138 142 else:
139   - self.set_signed_cookie('perguntations_user', str(uid))
140   - self.redirect('/')
  143 + self.set_secure_cookie("perguntations_user", str(uid))
  144 + self.redirect("/")
141 145  
142 146  
143 147 # ----------------------------------------------------------------------------
144 148 # pylint: disable=abstract-method
145 149 class LogoutHandler(BaseHandler):
146   - '''Handle /logout'''
  150 + """Handle /logout"""
147 151  
148 152 @tornado.web.authenticated
149 153 def get(self):
150   - '''Logs out a user.'''
  154 + """Logs out a user."""
151 155 self.testapp.logout(self.current_user)
152   - self.clear_cookie('perguntations_user')
153   - self.render('login.html', error='')
  156 + self.clear_cookie("perguntations_user")
  157 + self.render("login.html", error="")
154 158  
155 159  
156 160 # ----------------------------------------------------------------------------
... ... @@ -158,57 +162,64 @@ class LogoutHandler(BaseHandler):
158 162 # ----------------------------------------------------------------------------
159 163 # pylint: disable=abstract-method
160 164 class RootHandler(BaseHandler):
161   - '''
  165 + """
162 166 Presents test to student.
163 167 Receives answers, corrects the test and sends back the grade.
164 168 Redirects user 0 to /admin.
165   - '''
  169 + """
166 170  
167 171 _templates = {
168 172 # -- question templates --
169   - 'radio': 'question-radio.html',
170   - 'checkbox': 'question-checkbox.html',
171   - 'text': 'question-text.html',
172   - 'text-regex': 'question-text.html',
173   - 'numeric-interval': 'question-text.html',
174   - 'textarea': 'question-textarea.html',
  173 + "radio": "question-radio.html",
  174 + "checkbox": "question-checkbox.html",
  175 + "text": "question-text.html",
  176 + "text-regex": "question-text.html",
  177 + "numeric-interval": "question-text.html",
  178 + "textarea": "question-textarea.html",
175 179 # -- information panels --
176   - 'information': 'question-information.html',
177   - 'success': 'question-information.html',
178   - 'warning': 'question-information.html',
179   - 'alert': 'question-information.html',
  180 + "information": "question-information.html",
  181 + "success": "question-information.html",
  182 + "warning": "question-information.html",
  183 + "alert": "question-information.html",
180 184 }
181 185  
182 186 # --- GET
183 187 @tornado.web.authenticated
184 188 async def get(self):
185   - '''
  189 + """
186 190 Handles GET /
187 191 Sends test to student or redirects 0 to admin page.
188 192 Multiple calls to this function will return the same test.
189   - '''
  193 + """
190 194 uid = self.current_user
191 195 logger.debug('"%s" GET /', uid)
192 196  
193   - if uid == '0':
194   - self.redirect('/admin')
  197 + if uid == "0":
  198 + self.redirect("/admin")
195 199 else:
196 200 test = self.testapp.get_test(uid)
197 201 name = self.testapp.get_name(uid)
198   - self.render('test.html', t=test, uid=uid, name=name, md=md_to_html,
199   - templ=self._templates, debug=self.testapp.debug)
  202 + self.render(
  203 + "test.html",
  204 + t=test,
  205 + uid=uid,
  206 + name=name,
  207 + md=md_to_html,
  208 + templ=self._templates,
  209 + debug=self.testapp.debug,
  210 + )
200 211  
201 212 # --- POST
202 213 @tornado.web.authenticated
203 214 async def post(self):
204   - '''
  215 + """
205 216 Receives answers, fixes some html weirdness, corrects test and
206 217 renders the grade.
207 218  
208 219 self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']}
209 220 builds dictionary ans = {0: 'answer0', 1:, 'answer1', ...}
210 221 unanswered questions are not included.
211   - '''
  222 + """
212 223 starttime = timer() # performance timer
213 224  
214 225 uid = self.current_user
... ... @@ -220,42 +231,47 @@ class RootHandler(BaseHandler):
220 231 raise tornado.web.HTTPError(403) # Forbidden
221 232  
222 233 ans = {}
223   - for i, question in enumerate(test['questions']):
  234 + for i, question in enumerate(test["questions"]):
224 235 qid = str(i)
225   - if f'answered-{qid}' in self.request.arguments:
  236 + if f"answered-{qid}" in self.request.arguments:
226 237 ans[i] = self.get_body_arguments(qid)
227 238  
228 239 # remove enclosing list in some question types
229   - if question['type'] == 'radio':
  240 + if question["type"] == "radio":
230 241 ans[i] = ans[i][0] if ans[i] else None
231   - elif question['type'] in ('text', 'text-regex', 'textarea',
232   - 'numeric-interval'):
  242 + elif question["type"] in (
  243 + "text",
  244 + "text-regex",
  245 + "textarea",
  246 + "numeric-interval",
  247 + ):
233 248 ans[i] = ans[i][0]
234 249  
235 250 # submit answered questions, correct
236 251 await self.testapp.submit_test(uid, ans)
237 252  
238 253 name = self.testapp.get_name(uid)
239   - self.render('grade.html', t=test, uid=uid, name=name)
240   - self.clear_cookie('perguntations_user')
  254 + self.render("grade.html", t=test, uid=uid, name=name)
  255 + self.clear_cookie("perguntations_user")
241 256 self.testapp.logout(uid)
242   - logger.info(' elapsed time: %fs', timer() - starttime)
  257 + logger.info(" elapsed time: %fs", timer() - starttime)
243 258  
244 259  
245 260 # ----------------------------------------------------------------------------
246 261 # pylint: disable=abstract-method
  262 +# FIXME: also to update answers
247 263 class StudentWebservice(BaseHandler):
248   - '''
  264 + """
249 265 Receive ajax from students during the test in response to the events
250   - focus, unfocus and resize.
251   - '''
  266 + focus, unfocus and resize, etc.
  267 + """
252 268  
253 269 @tornado.web.authenticated
254 270 def post(self):
255   - '''handle ajax post'''
  271 + """handle ajax post"""
256 272 uid = self.current_user
257   - cmd = self.get_body_argument('cmd', None)
258   - value = self.get_body_argument('value', None)
  273 + cmd = self.get_body_argument("cmd", None)
  274 + value = self.get_body_argument("value", None)
259 275 if cmd is not None and value is not None:
260 276 self.testapp.register_event(uid, cmd, json.loads(value))
261 277  
... ... @@ -263,29 +279,31 @@ class StudentWebservice(BaseHandler):
263 279 # ----------------------------------------------------------------------------
264 280 # pylint: disable=abstract-method
265 281 class AdminWebservice(BaseHandler):
266   - '''
  282 + """
267 283 Receive ajax requests from admin
268   - '''
  284 + """
269 285  
270 286 @tornado.web.authenticated
271 287 @admin_only
272 288 async def get(self):
273   - '''admin webservices that do not change state'''
274   - cmd = self.get_query_argument('cmd')
275   - logger.debug('GET /adminwebservice %s', cmd)
  289 + """admin webservices that do not change state"""
  290 + cmd = self.get_query_argument("cmd")
  291 + logger.debug("GET /adminwebservice %s", cmd)
276 292  
277   - if cmd == 'testcsv':
  293 + if cmd == "testcsv":
278 294 test_ref, data = self.testapp.get_grades_csv()
279   - self.set_header('Content-Type', 'text/csv')
280   - self.set_header('content-Disposition',
281   - f'attachment; filename={test_ref}.csv')
  295 + self.set_header("Content-Type", "text/csv")
  296 + self.set_header(
  297 + "content-Disposition", f"attachment; filename={test_ref}.csv"
  298 + )
282 299 self.write(data)
283 300 await self.flush()
284   - elif cmd == 'questionscsv':
  301 + elif cmd == "questionscsv":
285 302 test_ref, data = self.testapp.get_detailed_grades_csv()
286   - self.set_header('Content-Type', 'text/csv')
287   - self.set_header('content-Disposition',
288   - f'attachment; filename={test_ref}-detailed.csv')
  303 + self.set_header("Content-Type", "text/csv")
  304 + self.set_header(
  305 + "content-Disposition", f"attachment; filename={test_ref}-detailed.csv"
  306 + )
289 307 self.write(data)
290 308 await self.flush()
291 309  
... ... @@ -293,52 +311,53 @@ class AdminWebservice(BaseHandler):
293 311 # ----------------------------------------------------------------------------
294 312 # pylint: disable=abstract-method
295 313 class AdminHandler(BaseHandler):
296   - '''Handle /admin'''
  314 + """Handle /admin"""
297 315  
298 316 # --- GET
299 317 @tornado.web.authenticated
300 318 @admin_only
301 319 async def get(self):
302   - '''
  320 + """
303 321 Admin page.
304   - '''
305   - cmd = self.get_query_argument('cmd', default=None)
306   - logger.debug('GET /admin (cmd=%s)', cmd)
  322 + """
  323 + cmd = self.get_query_argument("cmd", default=None)
  324 + logger.debug("GET /admin (cmd=%s)", cmd)
307 325  
308 326 if cmd is None:
309   - self.render('admin.html')
310   - elif cmd == 'test':
311   - data = { 'data': self.testapp.get_test_config() }
  327 + self.render("admin.html")
  328 + elif cmd == "test":
  329 + data = {"data": self.testapp.get_test_config()}
312 330 self.write(json.dumps(data, default=str))
313   - elif cmd == 'students_table':
314   - data = {'data': self.testapp.get_students_state()}
  331 + elif cmd == "students_table":
  332 + data = {"data": self.testapp.get_students_state()}
315 333 self.write(json.dumps(data, default=str))
316 334  
317 335 # --- POST
318 336 @tornado.web.authenticated
319 337 @admin_only
320 338 async def post(self):
321   - '''
  339 + """
322 340 Executes commands from the admin page.
323   - '''
324   - cmd = self.get_body_argument('cmd', None)
325   - value = self.get_body_argument('value', None)
326   - logger.debug('POST /admin (cmd=%s, value=%s)', cmd, value)
  341 + """
  342 + cmd = self.get_body_argument("cmd", None)
  343 + value = self.get_body_argument("value", None)
  344 + logger.debug("POST /admin (cmd=%s, value=%s)", cmd, value)
327 345  
328   - if cmd == 'allow':
  346 + if cmd == "allow":
329 347 self.testapp.allow_student(value)
330   - elif cmd == 'deny':
  348 + elif cmd == "deny":
331 349 self.testapp.deny_student(value)
332   - elif cmd == 'allow_all':
  350 + elif cmd == "allow_all":
333 351 self.testapp.allow_all_students()
334   - elif cmd == 'deny_all':
  352 + elif cmd == "deny_all":
335 353 self.testapp.deny_all_students()
336   - elif cmd == 'reset_password':
337   - await self.testapp.set_password(uid=value, password='')
338   - elif cmd == 'insert_student' and value is not None:
  354 + elif cmd == "reset_password":
  355 + await self.testapp.set_password(uid=value, password="")
  356 + elif cmd == "insert_student" and value is not None:
339 357 student = json.loads(value)
340   - await self.testapp.insert_new_student(uid=student['number'],
341   - name=student['name'])
  358 + await self.testapp.insert_new_student(
  359 + uid=student["number"], name=student["name"]
  360 + )
342 361  
343 362  
344 363 # ----------------------------------------------------------------------------
... ... @@ -346,22 +365,22 @@ class AdminHandler(BaseHandler):
346 365 # ----------------------------------------------------------------------------
347 366 # pylint: disable=abstract-method
348 367 class FileHandler(BaseHandler):
349   - '''
  368 + """
350 369 Handles static files from questions like images, etc.
351   - '''
  370 + """
352 371  
353 372 _filecache: Dict[Tuple[str, str], bytes] = {}
354 373  
355 374 @tornado.web.authenticated
356 375 async def get(self):
357   - '''
  376 + """
358 377 Returns requested file. Files are obtained from the 'public' directory
359 378 of each question.
360   - '''
  379 + """
361 380 uid = self.current_user
362   - ref = self.get_query_argument('ref', None)
363   - image = self.get_query_argument('image', None)
364   - logger.debug('GET /file (ref=%s, image=%s)', ref, image)
  381 + ref = self.get_query_argument("ref", None)
  382 + image = self.get_query_argument("image", None)
  383 + logger.debug("GET /file (ref=%s, image=%s)", ref, image)
365 384  
366 385 if ref is None or image is None:
367 386 return
... ... @@ -369,7 +388,7 @@ class FileHandler(BaseHandler):
369 388 content_type = mimetypes.guess_type(image)[0]
370 389  
371 390 if (ref, image) in self._filecache:
372   - logger.debug('using cached file')
  391 + logger.debug("using cached file")
373 392 self.write(self._filecache[(ref, image)])
374 393 if content_type is not None:
375 394 self.set_header("Content-Type", content_type)
... ... @@ -379,16 +398,16 @@ class FileHandler(BaseHandler):
379 398 try:
380 399 test = self.testapp.get_test(uid)
381 400 except KeyError:
382   - logger.warning('Could not get test to serve image file')
  401 + logger.warning("Could not get test to serve image file")
383 402 raise tornado.web.HTTPError(404) from None # Not Found
384 403  
385 404 # search for the question that contains the image
386   - for question in test['questions']:
387   - if question['ref'] == ref:
388   - filepath = path.join(question['path'], 'public', image)
  405 + for question in test["questions"]:
  406 + if question["ref"] == ref:
  407 + filepath = path.join(question["path"], "public", image)
389 408  
390 409 try:
391   - with open(filepath, 'rb') as file:
  410 + with open(filepath, "rb") as file:
392 411 data = file.read()
393 412 except OSError:
394 413 logger.error('Error reading file "%s"', filepath)
... ... @@ -404,39 +423,39 @@ class FileHandler(BaseHandler):
404 423 # --- REVIEW -----------------------------------------------------------------
405 424 # pylint: disable=abstract-method
406 425 class ReviewHandler(BaseHandler):
407   - '''
  426 + """
408 427 Show test for review
409   - '''
  428 + """
410 429  
411 430 _templates = {
412   - 'radio': 'review-question-radio.html',
413   - 'checkbox': 'review-question-checkbox.html',
414   - 'text': 'review-question-text.html',
415   - 'text-regex': 'review-question-text.html',
416   - 'numeric-interval': 'review-question-text.html',
417   - 'textarea': 'review-question-text.html',
  431 + "radio": "review-question-radio.html",
  432 + "checkbox": "review-question-checkbox.html",
  433 + "text": "review-question-text.html",
  434 + "text-regex": "review-question-text.html",
  435 + "numeric-interval": "review-question-text.html",
  436 + "textarea": "review-question-text.html",
418 437 # -- information panels --
419   - 'information': 'review-question-information.html',
420   - 'success': 'review-question-information.html',
421   - 'warning': 'review-question-information.html',
422   - 'alert': 'review-question-information.html',
  438 + "information": "review-question-information.html",
  439 + "success": "review-question-information.html",
  440 + "warning": "review-question-information.html",
  441 + "alert": "review-question-information.html",
423 442 }
424 443  
425 444 @tornado.web.authenticated
426 445 @admin_only
427 446 async def get(self):
428   - '''
  447 + """
429 448 Opens JSON file with a given corrected test and renders it
430   - '''
431   - test_id = self.get_query_argument('test_id', None)
432   - logger.info('Review test %s.', test_id)
  449 + """
  450 + test_id = self.get_query_argument("test_id", None)
  451 + logger.info("Review test %s.", test_id)
433 452 fname = self.testapp.get_json_filename_of_test(test_id)
434 453  
435 454 if fname is None:
436 455 raise tornado.web.HTTPError(404) # Not Found
437 456  
438 457 try:
439   - with open(path.expanduser(fname), encoding='utf-8') as jsonfile:
  458 + with open(path.expanduser(fname), encoding="utf-8") as jsonfile:
440 459 test = json.load(jsonfile)
441 460 except OSError:
442 461 msg = f'Cannot open "{fname}" for review.'
... ... @@ -447,57 +466,65 @@ class ReviewHandler(BaseHandler):
447 466 logger.error(msg)
448 467 raise tornado.web.HTTPError(status_code=404, reason=msg)
449 468  
450   - uid = test['student']
  469 + uid = test["student"]
451 470 name = self.testapp.get_name(uid)
452   - self.render('review.html', t=test, uid=uid, name=name, md=md_to_html,
453   - templ=self._templates, debug=self.testapp.debug)
  471 + self.render(
  472 + "review.html",
  473 + t=test,
  474 + uid=uid,
  475 + name=name,
  476 + md=md_to_html,
  477 + templ=self._templates,
  478 + debug=self.testapp.debug,
  479 + )
454 480  
455 481  
456 482 # ----------------------------------------------------------------------------
457 483 def signal_handler(*_):
458   - '''
  484 + """
459 485 Catches Ctrl-C and stops webserver
460   - '''
461   - reply = input(' --> Stop webserver? (yes/no) ')
462   - if reply.lower() == 'yes':
  486 + """
  487 + reply = input(" --> Stop webserver? (yes/no) ")
  488 + if reply.lower() == "yes":
463 489 tornado.ioloop.IOLoop.current().stop()
464   - logger.critical('Webserver stopped.')
  490 + logger.critical("Webserver stopped.")
465 491 sys.exit(0)
466 492  
  493 +
467 494 # ----------------------------------------------------------------------------
468 495 def run_webserver(app, ssl_opt, port, debug):
469   - '''
  496 + """
470 497 Starts and runs webserver until a SIGINT signal (Ctrl-C) is received.
471   - '''
  498 + """
472 499  
473 500 # --- create web application
474   - logger.info('-------- Starting WebApplication (tornado) --------')
  501 + logger.info("-------- Starting WebApplication (tornado) --------")
475 502 try:
476 503 webapp = WebApplication(app, debug=debug)
477 504 except Exception:
478   - logger.critical('Failed to start web application.')
  505 + logger.critical("Failed to start web application.")
479 506 raise
480 507  
481 508 # --- create httpserver
482 509 try:
483 510 httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt)
484 511 except ValueError:
485   - logger.critical('Certificates cert.pem, privkey.pem not found')
  512 + logger.critical("Certificates cert.pem, privkey.pem not found")
486 513 sys.exit(1)
487 514  
488 515 try:
489 516 httpserver.listen(port)
490 517 except OSError:
491   - logger.critical('Cannot bind port %d. Already in use?', port)
  518 + logger.critical("Cannot bind port %d. Already in use?", port)
492 519 sys.exit(1)
493 520  
494   - logger.info('Listening on port %d... (Ctrl-C to stop)', port)
  521 + logger.info("Listening on port %d... (Ctrl-C to stop)", port)
495 522 signal.signal(signal.SIGINT, signal_handler)
496 523  
497 524 # --- run webserver
498 525 try:
499 526 tornado.ioloop.IOLoop.current().start() # running...
500 527 except Exception:
501   - logger.critical('Webserver stopped!')
  528 + logger.critical("Webserver stopped!")
502 529 tornado.ioloop.IOLoop.current().stop()
503 530 raise
... ...
perguntations/templates/question-textarea.html
... ... @@ -4,4 +4,6 @@
4 4  
5 5 <textarea class="form-control" name="{{i}}" autocomplete="off">{{q['answer'] or ''}}</textarea><br />
6 6  
  7 +<button>Verificar sintaxe</button>
  8 +
7 9 {% end %}
... ...
perguntations/test.py
1   -'''
  1 +"""
2 2 Test - instances of this class are individual tests
3   -'''
  3 +"""
4 4  
5 5 # python standard library
6 6 from datetime import datetime
... ... @@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
14 14  
15 15 # ============================================================================
16 16 class Test(dict):
17   - '''
  17 + """
18 18 Each instance Test() is a concrete test of a single student.
19 19 A test can be in one of the states: ACTIVE, SUBMITTED, CORRECTED, QUIT
20 20 Methods:
... ... @@ -26,88 +26,90 @@ class Test(dict):
26 26 t.correct() - corrects questions and compute grade, register state
27 27 t.giveup() - register the test as given up, answers are not corrected
28 28 t.save_json(filename) - save the current test to file in JSON format
29   - '''
  29 + """
30 30  
31 31 # ------------------------------------------------------------------------
32 32 def __init__(self, d: dict):
33 33 super().__init__(d)
34   - self['grade'] = nan
35   - self['comment'] = ''
  34 + self["grade"] = nan
  35 + self["comment"] = ""
36 36  
37 37 # ------------------------------------------------------------------------
38 38 def start(self, uid: str) -> None:
39   - '''
  39 + """
40 40 Register student id and start time in the test
41   - '''
42   - self['student'] = uid
43   - self['start_time'] = datetime.now()
44   - self['finish_time'] = None
45   - self['state'] = 'ACTIVE'
  41 + """
  42 + self["student"] = uid
  43 + self["start_time"] = datetime.now()
  44 + self["finish_time"] = None
  45 + self["state"] = "ACTIVE"
46 46  
47 47 # ------------------------------------------------------------------------
48 48 def reset_answers(self) -> None:
49   - '''Removes all answers from the test (clean)'''
50   - for question in self['questions']:
51   - question['answer'] = None
  49 + """Removes all answers from the test (clean)"""
  50 + for question in self["questions"]:
  51 + question["answer"] = None
52 52  
53 53 # ------------------------------------------------------------------------
54 54 def update_answer(self, ref: str, ans) -> None:
55   - '''updates one answer in the test'''
56   - self['questions'][ref].set_answer(ans)
  55 + """updates one answer in the test"""
  56 + self["questions"][ref].set_answer(ans)
57 57  
58 58 # ------------------------------------------------------------------------
59 59 def submit(self, answers: dict) -> None:
60   - '''
  60 + """
61 61 Given a dictionary ans={'ref': 'some answer'} updates the answers of
62 62 multiple questions in the test.
63 63 Only affects the questions referred in the dictionary.
64   - '''
65   - self['finish_time'] = datetime.now()
  64 + """
  65 + self["finish_time"] = datetime.now()
66 66 for ref, ans in answers.items():
67   - self['questions'][ref].set_answer(ans)
68   - self['state'] = 'SUBMITTED'
  67 + self["questions"][ref].set_answer(ans)
  68 + self["state"] = "SUBMITTED"
69 69  
70 70 # ------------------------------------------------------------------------
71 71 async def correct_async(self) -> None:
72   - '''Corrects all the answers of the test and computes the final grade'''
  72 + """Corrects all the answers of the test and computes the final grade"""
73 73 grade = 0.0
74   - for question in self['questions']:
  74 + for question in self["questions"]:
75 75 await question.correct_async()
76   - grade += question['grade'] * question['points']
77   - logger.debug('Correcting %30s: %3g%%',
78   - question['ref'], question['grade']*100)
  76 + grade += question["grade"] * question["points"]
  77 + logger.debug(
  78 + "Correcting %30s: %3g%%", question["ref"], question["grade"] * 100
  79 + )
79 80  
80 81 # truncate to avoid negative final grade and adjust scale
81   - self['grade'] = max(0.0, grade) + self['scale'][0]
82   - self['state'] = 'CORRECTED'
  82 + self["grade"] = max(0.0, grade) + self["scale"][0]
  83 + self["state"] = "CORRECTED"
83 84  
84 85 # ------------------------------------------------------------------------
85 86 def correct(self) -> None:
86   - '''Corrects all the answers of the test and computes the final grade'''
  87 + """Corrects all the answers of the test and computes the final grade"""
87 88 grade = 0.0
88   - for question in self['questions']:
  89 + for question in self["questions"]:
89 90 question.correct()
90   - grade += question['grade'] * question['points']
91   - logger.debug('Correcting %30s: %3g%%',
92   - question['ref'], question['grade']*100)
  91 + grade += question["grade"] * question["points"]
  92 + logger.debug(
  93 + "Correcting %30s: %3g%%", question["ref"], question["grade"] * 100
  94 + )
93 95  
94 96 # truncate to avoid negative final grade and adjust scale
95   - self['grade'] = max(0.0, grade) + self['scale'][0]
96   - self['state'] = 'CORRECTED'
  97 + self["grade"] = max(0.0, grade) + self["scale"][0]
  98 + self["state"] = "CORRECTED"
97 99  
98 100 # ------------------------------------------------------------------------
99 101 def giveup(self) -> None:
100   - '''Test is marqued as QUIT and is not corrected'''
101   - self['finish_time'] = datetime.now()
102   - self['state'] = 'QUIT'
103   - self['grade'] = 0.0
  102 + """Test is marqued as QUIT and is not corrected"""
  103 + self["finish_time"] = datetime.now()
  104 + self["state"] = "QUIT"
  105 + self["grade"] = 0.0
104 106  
105 107 # ------------------------------------------------------------------------
106 108 def save_json(self, filename: str) -> None:
107   - '''save test in JSON format'''
108   - with open(filename, 'w', encoding='utf-8') as file:
  109 + """save test in JSON format"""
  110 + with open(filename, "w", encoding="utf-8") as file:
109 111 json.dump(self, file, indent=2, default=str) # str for datetime
110 112  
111 113 # ------------------------------------------------------------------------
112 114 def __str__(self) -> str:
113   - return '\n'.join([f'{k}: {v}' for k,v in self.items()])
  115 + return "\n".join([f"{k}: {v}" for k, v in self.items()])
... ...
perguntations/testfactory.py
1   -'''
  1 +"""
2 2 TestFactory - generates tests for students
3   -'''
  3 +"""
4 4  
5 5 # python standard library
6 6 import asyncio
... ... @@ -21,17 +21,18 @@ logger = logging.getLogger(__name__)
21 21  
22 22 # --- test validation --------------------------------------------------------
23 23 def check_answers_directory(ans: str) -> bool:
24   - '''Checks is answers_dir exists and is writable'''
25   - testfile = path.join(path.expanduser(ans), 'REMOVE-ME')
  24 + """Checks is answers_dir exists and is writable"""
  25 + testfile = path.join(path.expanduser(ans), "REMOVE-ME")
26 26 try:
27   - with open(testfile, 'w', encoding='utf-8') as file:
28   - file.write('You can safely remove this file.')
  27 + with open(testfile, "w", encoding="utf-8") as file:
  28 + file.write("You can safely remove this file.")
29 29 except OSError:
30 30 return False
31 31 return True
32 32  
  33 +
33 34 def check_import_files(files: list) -> bool:
34   - '''Checks if the question files exist'''
  35 + """Checks if the question files exist"""
35 36 if not files:
36 37 return False
37 38 for file in files:
... ... @@ -39,63 +40,69 @@ def check_import_files(files: list) -&gt; bool:
39 40 return False
40 41 return True
41 42  
  43 +
42 44 def normalize_question_list(questions: list) -> None:
43   - '''convert question ref from string to list of string'''
  45 + """convert question ref from string to list of string"""
44 46 for question in questions:
45   - if isinstance(question['ref'], str):
46   - question['ref'] = [question['ref']]
47   -
48   -test_schema = schema.Schema({
49   - 'ref': schema.Regex('^[a-zA-Z0-9_-]+$'),
50   - 'database': schema.And(str, path.isfile),
51   - 'answers_dir': schema.And(str, check_answers_directory),
52   - 'title': str,
53   - schema.Optional('duration'): int,
54   - schema.Optional('autosubmit'): bool,
55   - schema.Optional('autocorrect'): bool,
56   - schema.Optional('show_points'): bool,
57   - schema.Optional('scale'): schema.And([schema.Use(float)],
58   - lambda s: len(s) == 2),
59   - 'files': schema.And([str], check_import_files),
60   - 'questions': [{
61   - 'ref': schema.Or(str, [str]),
62   - schema.Optional('points'): float
63   - }]
64   - }, ignore_extra_keys=True)
  47 + if isinstance(question["ref"], str):
  48 + question["ref"] = [question["ref"]]
  49 +
  50 +
  51 +test_schema = schema.Schema(
  52 + {
  53 + "ref": schema.Regex("^[a-zA-Z0-9_-]+$"),
  54 + "database": schema.And(str, path.isfile),
  55 + "answers_dir": schema.And(str, check_answers_directory),
  56 + "title": str,
  57 + schema.Optional("duration"): int,
  58 + schema.Optional("autosubmit"): bool,
  59 + schema.Optional("autocorrect"): bool,
  60 + schema.Optional("show_points"): bool,
  61 + schema.Optional("scale"): schema.And(
  62 + [schema.Use(float)], lambda s: len(s) == 2
  63 + ),
  64 + "files": schema.And([str], check_import_files),
  65 + "questions": [{"ref": schema.Or(str, [str]), schema.Optional("points"): float}],
  66 + },
  67 + ignore_extra_keys=True,
  68 +)
  69 +# FIXME schema error with 'testfile' which is added in the code
65 70  
66 71 # ============================================================================
67 72 class TestFactoryException(Exception):
68   - '''exception raised in this module'''
  73 + """exception raised in this module"""
69 74  
70 75  
71 76 # ============================================================================
72 77 class TestFactory(dict):
73   - '''
  78 + """
74 79 Each instance of TestFactory() is a test generator.
75 80 For example, if we want to serve two different tests, then we need two
76 81 instances of TestFactory(), one for each test.
77   - '''
  82 + """
78 83  
79 84 # ------------------------------------------------------------------------
80 85 def __init__(self, conf) -> None:
81   - '''
  86 + """
82 87 Loads configuration from yaml file, then overrides some configurations
83 88 using the conf argument.
84 89 Base questions are added to a pool of questions factories.
85   - '''
  90 + """
86 91  
87 92 test_schema.validate(conf)
88 93  
89 94 # --- set test defaults and then use given configuration
90   - super().__init__({ # defaults
91   - 'show_points': True,
92   - 'scale': None,
93   - 'duration': 0, # 0=infinite
94   - 'autosubmit': False,
95   - 'autocorrect': True,
96   - })
  95 + super().__init__(
  96 + { # defaults
  97 + "show_points": True,
  98 + "scale": None,
  99 + "duration": 0, # 0=infinite
  100 + "autosubmit": False,
  101 + "autocorrect": True,
  102 + }
  103 + )
97 104 self.update(conf)
98   - normalize_question_list(self['questions'])
  105 + normalize_question_list(self["questions"])
99 106  
100 107 # --- for review, we are done. no factories needed
101 108 # if self['review']: FIXME
... ... @@ -103,53 +110,57 @@ class TestFactory(dict):
103 110 # return
104 111  
105 112 # --- find refs of all questions used in the test
106   - qrefs = {r for qq in self['questions'] for r in qq['ref']}
107   - logger.info('Declared %d questions (each test uses %d).',
108   - len(qrefs), len(self["questions"]))
  113 + qrefs = {r for qq in self["questions"] for r in qq["ref"]}
  114 + logger.info(
  115 + "Declared %d questions (each test uses %d).",
  116 + len(qrefs),
  117 + len(self["questions"]),
  118 + )
109 119  
110 120 # --- load and build question factories
111   - self['question_factory'] = {}
  121 + self["question_factory"] = {}
112 122  
113 123 for file in self["files"]:
114 124 fullpath = path.normpath(file)
115 125  
116 126 logger.info('Loading "%s"...', fullpath)
117   - questions = load_yaml(fullpath) # , default=[])
  127 + questions = load_yaml(fullpath) # , default=[])
118 128  
119 129 for i, question in enumerate(questions):
120 130 # make sure every question in the file is a dictionary
121 131 if not isinstance(question, dict):
122   - msg = f'Question {i} in {file} is not a dictionary'
  132 + msg = f"Question {i} in {file} is not a dictionary"
123 133 raise TestFactoryException(msg)
124 134  
125 135 # check if ref is missing, then set to '//file.yaml:3'
126   - if 'ref' not in question:
127   - question['ref'] = f'{file}:{i:04}'
  136 + if "ref" not in question:
  137 + question["ref"] = f"{file}:{i:04}"
128 138 logger.warning('Missing ref set to "%s"', question["ref"])
129 139  
130 140 # check for duplicate refs
131   - qref = question['ref']
132   - if qref in self['question_factory']:
133   - other = self['question_factory'][qref]
134   - otherfile = path.join(other.question['path'],
135   - other.question['filename'])
  141 + qref = question["ref"]
  142 + if qref in self["question_factory"]:
  143 + other = self["question_factory"][qref]
  144 + otherfile = path.join(
  145 + other.question["path"], other.question["filename"]
  146 + )
136 147 msg = f'Duplicate "{qref}" in {otherfile} and {fullpath}'
137 148 raise TestFactoryException(msg)
138 149  
139 150 # make factory only for the questions used in the test
140 151 if qref in qrefs:
141   - question.update(zip(('path', 'filename', 'index'),
142   - path.split(fullpath) + (i,)))
143   - self['question_factory'][qref] = QFactory(QDict(question))
  152 + question.update(
  153 + zip(("path", "filename", "index"), path.split(fullpath) + (i,))
  154 + )
  155 + self["question_factory"][qref] = QFactory(QDict(question))
144 156  
145   - qmissing = qrefs.difference(set(self['question_factory'].keys()))
  157 + qmissing = qrefs.difference(set(self["question_factory"].keys()))
146 158 if qmissing:
147   - raise TestFactoryException(f'Could not find questions {qmissing}.')
  159 + raise TestFactoryException(f"Could not find questions {qmissing}.")
148 160  
149 161 self.check_questions()
150 162  
151   - logger.info('Test factory ready. No errors found.')
152   -
  163 + logger.info("Test factory ready. No errors found.")
153 164  
154 165 # ------------------------------------------------------------------------
155 166 # def check_test_ref(self) -> None:
... ... @@ -201,8 +212,8 @@ class TestFactory(dict):
201 212 # 'question files to import!')
202 213 # raise TestFactoryException(msg)
203 214  
204   - # if isinstance(self['files'], str):
205   - # self['files'] = [self['files']]
  215 + # if isinstance(self['files'], str):
  216 + # self['files'] = [self['files']]
206 217  
207 218 # def check_question_list(self) -> None:
208 219 # '''normalize question list'''
... ... @@ -234,124 +245,140 @@ class TestFactory(dict):
234 245 # logger.warning(msg)
235 246 # self['scale'] = [self['scale_min'], self['scale_max']]
236 247  
237   -
238 248 # ------------------------------------------------------------------------
239 249 # def sanity_checks(self) -> None:
240 250 # '''
241 251 # Checks for valid keys and sets default values.
242 252 # Also checks if some files and directories exist
243 253 # '''
244   - # self.check_test_ref()
245   - # self.check_missing_database()
246   - # self.check_missing_answers_directory()
247   - # self.check_answers_directory_writable()
248   - # self.check_questions_directory()
249   - # self.check_import_files()
250   - # self.check_question_list()
251   - # self.check_missing_title()
252   - # self.check_grade_scaling()
  254 + # self.check_test_ref()
  255 + # self.check_missing_database()
  256 + # self.check_missing_answers_directory()
  257 + # self.check_answers_directory_writable()
  258 + # self.check_questions_directory()
  259 + # self.check_import_files()
  260 + # self.check_question_list()
  261 + # self.check_missing_title()
  262 + # self.check_grade_scaling()
253 263  
254 264 # ------------------------------------------------------------------------
255 265 def check_questions(self) -> None:
256   - '''
  266 + """
257 267 checks if questions can be correctly generated and corrected
258   - '''
259   - logger.info('Checking questions...')
  268 + """
  269 + logger.info("Checking questions...")
260 270 # FIXME get_event_loop will be deprecated in python3.10
261 271 loop = asyncio.get_event_loop()
262   - for i, (qref, qfact) in enumerate(self['question_factory'].items()):
  272 + for i, (qref, qfact) in enumerate(self["question_factory"].items()):
263 273 try:
264 274 question = loop.run_until_complete(qfact.gen_async())
265 275 except Exception as exc:
266 276 msg = f'Failed to generate "{qref}"'
267 277 raise TestFactoryException(msg) from exc
268 278 else:
269   - logger.info('%4d. %s: Ok', i, qref)
  279 + logger.info("%4d. %s: Ok", i, qref)
270 280  
271   - if question['type'] == 'textarea':
  281 + if question["type"] == "textarea":
272 282 _runtests_textarea(qref, question)
273 283  
274 284 # ------------------------------------------------------------------------
275 285 async def generate(self):
276   - '''
  286 + """
277 287 Given a dictionary with a student dict {'name':'john', 'number': 123}
278 288 returns instance of Test() for that particular student
279   - '''
  289 + """
280 290  
281 291 # make list of questions
282 292 questions = []
283 293 qnum = 1 # track question number
284 294 nerr = 0 # count errors during questions generation
285 295  
286   - for qlist in self['questions']:
  296 + for qlist in self["questions"]:
287 297 # choose list of question variants
288   - choose = qlist.get('choose', 1)
289   - qrefs = random.sample(qlist['ref'], k=choose)
  298 + choose = qlist.get("choose", 1)
  299 + qrefs = random.sample(qlist["ref"], k=choose)
290 300  
291 301 for qref in qrefs:
292 302 # generate instance of question
293 303 try:
294   - question = await self['question_factory'][qref].gen_async()
  304 + question = await self["question_factory"][qref].gen_async()
295 305 except QuestionException:
296 306 logger.error('Can\'t generate question "%s". Skipping.', qref)
297 307 nerr += 1
298 308 continue
299 309  
300 310 # some defaults
301   - if question['type'] in ('information', 'success', 'warning',
302   - 'alert'):
303   - question['points'] = qlist.get('points', 0.0)
  311 + if question["type"] in ("information", "success", "warning", "alert"):
  312 + question["points"] = qlist.get("points", 0.0)
304 313 else:
305   - question['points'] = qlist.get('points', 1.0)
306   - question['number'] = qnum # counter for non informative panels
  314 + question["points"] = qlist.get("points", 1.0)
  315 + question["number"] = qnum # counter for non informative panels
307 316 qnum += 1
308 317  
309 318 questions.append(question)
310 319  
311 320 # setup scale
312   - total_points = sum(q['points'] for q in questions)
  321 + total_points = sum(q["points"] for q in questions)
313 322  
314 323 if total_points > 0:
315 324 # normalize question points to scale
316   - if self['scale'] is not None:
317   - scale_min, scale_max = self['scale']
  325 + if self["scale"] is not None:
  326 + scale_min, scale_max = self["scale"]
  327 + factor = (scale_max - scale_min) / total_points
318 328 for question in questions:
319   - question['points'] *= (scale_max - scale_min) / total_points
  329 + question["points"] *= factor
  330 + logger.debug(
  331 + "Points normalized from %g to [%g, %g]",
  332 + total_points,
  333 + scale_min,
  334 + scale_max,
  335 + )
320 336 else:
321   - self['scale'] = [0, total_points]
  337 + self["scale"] = [0, total_points]
322 338 else:
323   - logger.warning('Total points is **ZERO**.')
324   - if self['scale'] is None:
325   - self['scale'] = [0, 20] # default
  339 + logger.warning("Total points is **ZERO**.")
  340 + if self["scale"] is None:
  341 + self["scale"] = [0, 20] # default
326 342  
327 343 if nerr > 0:
328   - logger.error('%s errors found!', nerr)
  344 + logger.error("%s errors found!", nerr)
329 345  
330 346 # copy these from the test configuratoin to each test instance
331   - inherit = ['ref', 'title', 'database', 'answers_dir', 'files', 'scale',
332   - 'duration', 'autosubmit', 'autocorrect', 'show_points']
333   -
334   - return Test({'questions': questions, **{k:self[k] for k in inherit}})
  347 + inherit = [
  348 + "ref",
  349 + "title",
  350 + "database",
  351 + "answers_dir",
  352 + "files",
  353 + "scale",
  354 + "duration",
  355 + "autosubmit",
  356 + "autocorrect",
  357 + "show_points",
  358 + ]
  359 +
  360 + return Test({"questions": questions, **{k: self[k] for k in inherit}})
335 361  
336 362 # ------------------------------------------------------------------------
337 363 def __repr__(self):
338   - testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items())
339   - return 'TestFactory({\n' + testsettings + '\n})'
  364 + testsettings = "\n".join(f" {k:14s}: {v}" for k, v in self.items())
  365 + return "TestFactory({\n" + testsettings + "\n})"
  366 +
340 367  
341 368 # ============================================================================
342 369 def _runtests_textarea(qref, question):
343   - '''
  370 + """
344 371 Checks if correction script works and runs tests if available
345   - '''
  372 + """
346 373 try:
347   - question.set_answer('')
  374 + question.set_answer("")
348 375 question.correct()
349 376 except Exception as exc:
350 377 msg = f'Failed to correct "{qref}"'
351 378 raise TestFactoryException(msg) from exc
352   - logger.info(' correction works')
  379 + logger.info(" correction works")
353 380  
354   - for tnum, right_answer in enumerate(question.get('tests_right', {})):
  381 + for tnum, right_answer in enumerate(question.get("tests_right", {})):
355 382 try:
356 383 question.set_answer(right_answer)
357 384 question.correct()
... ... @@ -359,12 +386,12 @@ def _runtests_textarea(qref, question):
359 386 msg = f'Failed to correct "{qref}"'
360 387 raise TestFactoryException(msg) from exc
361 388  
362   - if question['grade'] == 1.0:
363   - logger.info(' tests_right[%i] Ok', tnum)
  389 + if question["grade"] == 1.0:
  390 + logger.info(" tests_right[%i] Ok", tnum)
364 391 else:
365   - logger.error(' tests_right[%i] FAILED!!!', tnum)
  392 + logger.error(" tests_right[%i] FAILED!!!", tnum)
366 393  
367   - for tnum, wrong_answer in enumerate(question.get('tests_wrong', {})):
  394 + for tnum, wrong_answer in enumerate(question.get("tests_wrong", {})):
368 395 try:
369 396 question.set_answer(wrong_answer)
370 397 question.correct()
... ... @@ -372,7 +399,7 @@ def _runtests_textarea(qref, question):
372 399 msg = f'Failed to correct "{qref}"'
373 400 raise TestFactoryException(msg) from exc
374 401  
375   - if question['grade'] < 1.0:
376   - logger.info(' tests_wrong[%i] Ok', tnum)
  402 + if question["grade"] < 1.0:
  403 + logger.info(" tests_wrong[%i] Ok", tnum)
377 404 else:
378   - logger.error(' tests_wrong[%i] FAILED!!!', tnum)
  405 + logger.error(" tests_wrong[%i] FAILED!!!", tnum)
... ...
perguntations/tools.py
1   -'''
  1 +"""
2 2 File: perguntations/tools.py
3 3 Description: Helper functions to load yaml files and run external programs.
4   -'''
  4 +"""
5 5  
6 6  
7 7 # python standard library
... ... @@ -21,20 +21,18 @@ logger = logging.getLogger(__name__)
21 21  
22 22 # ----------------------------------------------------------------------------
23 23 def load_yaml(filename: str) -> Any:
24   - '''load yaml file or raise exception on error'''
25   - with open(path.expanduser(filename), 'r', encoding='utf-8') as file:
  24 + """load yaml file or raise exception on error"""
  25 + with open(path.expanduser(filename), "r", encoding="utf-8") as file:
26 26 return yaml.safe_load(file)
27 27  
  28 +
28 29 # ---------------------------------------------------------------------------
29   -def run_script(script: str,
30   - args: List[str],
31   - stdin: str = '',
32   - timeout: int = 3) -> Any:
33   - '''
  30 +def run_script(script: str, args: List[str], stdin: str = "", timeout: int = 3) -> Any:
  31 + """
34 32 Runs a script and returns its stdout parsed as yaml, or None on error.
35 33 The script is run in another process but this function blocks waiting
36 34 for its termination.
37   - '''
  35 + """
38 36 logger.debug('run_script "%s"', script)
39 37  
40 38 output = None
... ... @@ -43,14 +41,15 @@ def run_script(script: str,
43 41  
44 42 # --- run process
45 43 try:
46   - proc = subprocess.run(cmd,
47   - input=stdin,
48   - stdout=subprocess.PIPE,
49   - stderr=subprocess.STDOUT,
50   - universal_newlines=True,
51   - timeout=timeout,
52   - check=True,
53   - )
  44 + proc = subprocess.run(
  45 + cmd,
  46 + input=stdin,
  47 + stdout=subprocess.PIPE,
  48 + stderr=subprocess.STDOUT,
  49 + universal_newlines=True,
  50 + timeout=timeout,
  51 + check=True,
  52 + )
54 53 except subprocess.TimeoutExpired:
55 54 logger.error('Timeout %ds exceeded running "%s".', timeout, script)
56 55 return output
... ... @@ -71,11 +70,10 @@ def run_script(script: str,
71 70  
72 71  
73 72 # ----------------------------------------------------------------------------
74   -async def run_script_async(script: str,
75   - args: List[str],
76   - stdin: str = '',
77   - timeout: int = 3) -> Any:
78   - '''Same as above, but asynchronous'''
  73 +async def run_script_async(
  74 + script: str, args: List[str], stdin: str = "", timeout: int = 3
  75 +) -> Any:
  76 + """Same as above, but asynchronous"""
79 77  
80 78 script = path.expanduser(script)
81 79 args = [str(a) for a in args]
... ... @@ -84,11 +82,12 @@ async def run_script_async(script: str,
84 82 # --- start process
85 83 try:
86 84 proc = await asyncio.create_subprocess_exec(
87   - script, *args,
  85 + script,
  86 + *args,
88 87 stdin=asyncio.subprocess.PIPE,
89 88 stdout=asyncio.subprocess.PIPE,
90 89 stderr=asyncio.subprocess.DEVNULL,
91   - )
  90 + )
92 91 except OSError:
93 92 logger.error('Can not execute script "%s".', script)
94 93 return output
... ... @@ -96,8 +95,8 @@ async def run_script_async(script: str,
96 95 # --- send input and wait for termination
97 96 try:
98 97 stdout, _ = await asyncio.wait_for(
99   - proc.communicate(input=stdin.encode('utf-8')),
100   - timeout=timeout)
  98 + proc.communicate(input=stdin.encode("utf-8")), timeout=timeout
  99 + )
101 100 except asyncio.TimeoutError:
102 101 logger.warning('Timeout %ds running script "%s".', timeout, script)
103 102 return output
... ... @@ -109,7 +108,7 @@ async def run_script_async(script: str,
109 108  
110 109 # --- parse yaml
111 110 try:
112   - output = yaml.safe_load(stdout.decode('utf-8', 'ignore'))
  111 + output = yaml.safe_load(stdout.decode("utf-8", "ignore"))
113 112 except yaml.YAMLError:
114 113 logger.error('Error parsing yaml output of "%s"', script)
115 114  
... ...