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