Commit bb42f4ab0ea18b327cf96f1f6a53457535fb415e

Authored by Miguel Barão
1 parent 51392b56
Exists in dev

Code reformat using black.

perguntations/__init__.py
@@ -21,7 +21,7 @@ @@ -21,7 +21,7 @@
21 # SOFTWARE. 21 # SOFTWARE.
22 22
23 23
24 -'''A webserver for running tests in class 24 +"""A webserver for running tests in class
25 25
26 perguntations is a webserver that is intended to be run in an isolated 26 perguntations is a webserver that is intended to be run in an isolated
27 local network in classroom environment. 27 local network in classroom environment.
@@ -29,13 +29,13 @@ The server will run a preconfigured test and serve the test as a web form. @@ -29,13 +29,13 @@ The server will run a preconfigured test and serve the test as a web form.
29 The answers submitted by the students are immediatly checked and classified. 29 The answers submitted by the students are immediatly checked and classified.
30 The grades are stored in a database and the tests are stored in JSON files as 30 The grades are stored in a database and the tests are stored in JSON files as
31 proof of submission and for review. 31 proof of submission and for review.
32 -''' 32 +"""
33 33
34 -APP_NAME = 'perguntations'  
35 -APP_VERSION = '2022.04.dev1' 34 +APP_NAME = "perguntations"
  35 +APP_VERSION = "2022.04.dev1"
36 APP_DESCRIPTION = str(__doc__) 36 APP_DESCRIPTION = str(__doc__)
37 37
38 -__author__ = 'Miguel Barão'  
39 -__copyright__ = 'Copyright 2022, Miguel Barão'  
40 -__license__ = 'MIT license' 38 +__author__ = "Miguel Barão"
  39 +__copyright__ = "Copyright 2022, Miguel Barão"
  40 +__license__ = "MIT license"
41 __version__ = APP_VERSION 41 __version__ = APP_VERSION
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
@@ -30,68 +30,73 @@ from .questions import question_from @@ -30,68 +30,73 @@ from .questions import question_from
30 # setup logger for this module 30 # setup logger for this module
31 logger = logging.getLogger(__name__) 31 logger = logging.getLogger(__name__)
32 32
  33 +
33 async def check_password(password: str, hashed: bytes) -> bool: 34 async def check_password(password: str, hashed: bytes) -> bool:
34 - '''check password in executor''' 35 + """check password in executor"""
35 loop = asyncio.get_running_loop() 36 loop = asyncio.get_running_loop()
36 - return await loop.run_in_executor(None, bcrypt.checkpw,  
37 - password.encode('utf-8'), hashed) 37 + return await loop.run_in_executor(
  38 + None, bcrypt.checkpw, password.encode("utf-8"), hashed
  39 + )
  40 +
38 41
39 async def hash_password(password: str) -> bytes: 42 async def hash_password(password: str) -> bytes:
40 - '''get hash for password''' 43 + """get hash for password"""
41 loop = asyncio.get_running_loop() 44 loop = asyncio.get_running_loop()
42 - return await loop.run_in_executor(None, bcrypt.hashpw,  
43 - password.encode('utf-8'), bcrypt.gensalt()) 45 + return await loop.run_in_executor(
  46 + None, bcrypt.hashpw, password.encode("utf-8"), bcrypt.gensalt()
  47 + )
  48 +
44 49
45 # ============================================================================ 50 # ============================================================================
46 class AppException(Exception): 51 class AppException(Exception):
47 - '''Exception raised in this module''' 52 + """Exception raised in this module"""
  53 +
48 54
49 # ============================================================================ 55 # ============================================================================
50 # main application 56 # main application
51 # ============================================================================ 57 # ============================================================================
52 -class App():  
53 - ''' 58 +class App:
  59 + """
54 Main application 60 Main application
55 - ''' 61 + """
56 62
57 # ------------------------------------------------------------------------ 63 # ------------------------------------------------------------------------
58 def __init__(self, config): 64 def __init__(self, config):
59 - self.debug = config['debug']  
60 - self._make_test_factory(config['testfile']) 65 + self.debug = config["debug"]
  66 + self._make_test_factory(config["testfile"])
61 self._db_setup() # setup engine and load all students 67 self._db_setup() # setup engine and load all students
62 68
63 # FIXME get_event_loop will be deprecated in python3.10 69 # FIXME get_event_loop will be deprecated in python3.10
64 asyncio.get_event_loop().run_until_complete(self._assign_tests()) 70 asyncio.get_event_loop().run_until_complete(self._assign_tests())
65 71
66 # command line options: --allow-all, --allow-list filename 72 # command line options: --allow-all, --allow-list filename
67 - if config['allow_all']: 73 + if config["allow_all"]:
68 self.allow_all_students() 74 self.allow_all_students()
69 - elif config['allow_list'] is not None:  
70 - self.allow_from_list(config['allow_list']) 75 + elif config["allow_list"] is not None:
  76 + self.allow_from_list(config["allow_list"])
71 else: 77 else:
72 - logger.info('Students not allowed to login') 78 + logger.info("Students not allowed to login")
73 79
74 - if config['correct']: 80 + if config["correct"]:
75 self._correct_tests() 81 self._correct_tests()
76 82
77 # ------------------------------------------------------------------------ 83 # ------------------------------------------------------------------------
78 def _db_setup(self) -> None: 84 def _db_setup(self) -> None:
79 - ''' 85 + """
80 Create database engine and checks for admin and students 86 Create database engine and checks for admin and students
81 - '''  
82 - dbfile = os.path.expanduser(self._testfactory['database']) 87 + """
  88 + dbfile = os.path.expanduser(self._testfactory["database"])
83 logger.debug('Checking database "%s"...', dbfile) 89 logger.debug('Checking database "%s"...', dbfile)
84 if not os.path.exists(dbfile): 90 if not os.path.exists(dbfile):
85 raise AppException('No database, use "initdb" to create') 91 raise AppException('No database, use "initdb" to create')
86 92
87 # connect to database and check for admin & registered students 93 # connect to database and check for admin & registered students
88 - self._engine = create_engine(f'sqlite:///{dbfile}', future=True) 94 + self._engine = create_engine(f"sqlite:///{dbfile}", future=True)
89 try: 95 try:
90 with Session(self._engine, future=True) as session: 96 with Session(self._engine, future=True) as session:
91 - query = select(Student.id, Student.name)\  
92 - .where(Student.id != '0') 97 + query = select(Student.id, Student.name).where(Student.id != "0")
93 dbstudents = session.execute(query).all() 98 dbstudents = session.execute(query).all()
94 - session.execute(select(Student).where(Student.id == '0')).one() 99 + session.execute(select(Student).where(Student.id == "0")).one()
95 except NoResultFound: 100 except NoResultFound:
96 msg = 'Database has no administrator (user "0")' 101 msg = 'Database has no administrator (user "0")'
97 logger.error(msg) 102 logger.error(msg)
@@ -100,158 +105,162 @@ class App(): @@ -100,158 +105,162 @@ class App():
100 msg = f'Database "{dbfile}" unusable' 105 msg = f'Database "{dbfile}" unusable'
101 logger.error(msg) 106 logger.error(msg)
102 raise AppException(msg) from None 107 raise AppException(msg) from None
103 - logger.info('Database has %d students', len(dbstudents)) 108 + logger.info("Database has %d students", len(dbstudents))
104 109
105 - self._students = {uid: {  
106 - 'name': name,  
107 - 'state': 'offline',  
108 - 'test': None,  
109 - } for uid, name in dbstudents} 110 + self._students = {
  111 + uid: {
  112 + "name": name,
  113 + "state": "offline",
  114 + "test": None,
  115 + }
  116 + for uid, name in dbstudents
  117 + }
110 118
111 # ------------------------------------------------------------------------ 119 # ------------------------------------------------------------------------
112 async def _assign_tests(self) -> None: 120 async def _assign_tests(self) -> None:
113 - '''Generate tests for all students that don't yet have a test'''  
114 - logger.info('Generating tests...') 121 + """Generate tests for all students that don't yet have a test"""
  122 + logger.info("Generating tests...")
115 for student in self._students.values(): 123 for student in self._students.values():
116 - if student.get('test', None) is None:  
117 - student['test'] = await self._testfactory.generate()  
118 - logger.info('Tests assigned to all students') 124 + if student.get("test", None) is None:
  125 + student["test"] = await self._testfactory.generate()
  126 + logger.info("Tests assigned to all students")
119 127
120 # ------------------------------------------------------------------------ 128 # ------------------------------------------------------------------------
121 async def login(self, uid: str, password: str, headers: dict) -> Optional[str]: 129 async def login(self, uid: str, password: str, headers: dict) -> Optional[str]:
122 - ''' 130 + """
123 Login authentication 131 Login authentication
124 If successful returns None, else returns an error message 132 If successful returns None, else returns an error message
125 - ''' 133 + """
126 try: 134 try:
127 with Session(self._engine, future=True) as session: 135 with Session(self._engine, future=True) as session:
128 query = select(Student.password).where(Student.id == uid) 136 query = select(Student.password).where(Student.id == uid)
129 hashed = session.execute(query).scalar_one() 137 hashed = session.execute(query).scalar_one()
130 except NoResultFound: 138 except NoResultFound:
131 logger.warning('"%s" does not exist', uid) 139 logger.warning('"%s" does not exist', uid)
132 - return 'nonexistent' 140 + return "nonexistent"
133 141
134 - if uid != '0' and self._students[uid]['state'] != 'allowed': 142 + if uid != "0" and self._students[uid]["state"] != "allowed":
135 logger.warning('"%s" login not allowed', uid) 143 logger.warning('"%s" login not allowed', uid)
136 - return 'not_allowed' 144 + return "not_allowed"
137 145
138 - if hashed == '': # set password on first login 146 + if hashed == "": # set password on first login
139 await self.set_password(uid, password) 147 await self.set_password(uid, password)
140 elif not await check_password(password, hashed): 148 elif not await check_password(password, hashed):
141 logger.info('"%s" wrong password', uid) 149 logger.info('"%s" wrong password', uid)
142 - return 'wrong_password' 150 + return "wrong_password"
143 151
144 # success 152 # success
145 - if uid == '0':  
146 - logger.info('Admin login from %s', headers['remote_ip']) 153 + if uid == "0":
  154 + logger.info("Admin login from %s", headers["remote_ip"])
147 else: 155 else:
148 student = self._students[uid] 156 student = self._students[uid]
149 - student['test'].start(uid)  
150 - student['state'] = 'online'  
151 - student['headers'] = headers  
152 - student['unfocus'] = False  
153 - student['area'] = 0.0  
154 - logger.info('"%s" login from %s', uid, headers['remote_ip']) 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"])
155 return None 163 return None
156 164
157 # ------------------------------------------------------------------------ 165 # ------------------------------------------------------------------------
158 async def set_password(self, uid: str, password: str) -> None: 166 async def set_password(self, uid: str, password: str) -> None:
159 - '''change password in the database''' 167 + """change password in the database"""
160 with Session(self._engine, future=True) as session: 168 with Session(self._engine, future=True) as session:
161 query = select(Student).where(Student.id == uid) 169 query = select(Student).where(Student.id == uid)
162 student = session.execute(query).scalar_one() 170 student = session.execute(query).scalar_one()
163 - student.password = await hash_password(password) if password else '' 171 + student.password = await hash_password(password) if password else ""
164 session.commit() 172 session.commit()
165 logger.info('"%s" password updated', uid) 173 logger.info('"%s" password updated', uid)
166 174
167 # ------------------------------------------------------------------------ 175 # ------------------------------------------------------------------------
168 def logout(self, uid: str) -> None: 176 def logout(self, uid: str) -> None:
169 - '''student logout''' 177 + """student logout"""
170 student = self._students.get(uid, None) 178 student = self._students.get(uid, None)
171 if student is not None: 179 if student is not None:
172 # student['test'] = None 180 # student['test'] = None
173 - student['state'] = 'offline'  
174 - student.pop('headers', None)  
175 - student.pop('unfocus', None)  
176 - student.pop('area', None) 181 + student["state"] = "offline"
  182 + student.pop("headers", None)
  183 + student.pop("unfocus", None)
  184 + student.pop("area", None)
177 logger.info('"%s" logged out', uid) 185 logger.info('"%s" logged out', uid)
178 186
179 # ------------------------------------------------------------------------ 187 # ------------------------------------------------------------------------
180 def _make_test_factory(self, filename: str) -> None: 188 def _make_test_factory(self, filename: str) -> None:
181 - ''' 189 + """
182 Setup a factory for the test 190 Setup a factory for the test
183 - ''' 191 + """
184 192
185 # load configuration from yaml file 193 # load configuration from yaml file
186 try: 194 try:
187 testconf = load_yaml(filename) 195 testconf = load_yaml(filename)
188 - testconf['testfile'] = filename 196 + testconf["testfile"] = filename
189 except (OSError, yaml.YAMLError) as exc: 197 except (OSError, yaml.YAMLError) as exc:
190 msg = f'Cannot read test configuration "{filename}"' 198 msg = f'Cannot read test configuration "{filename}"'
191 logger.error(msg) 199 logger.error(msg)
192 raise AppException(msg) from exc 200 raise AppException(msg) from exc
193 201
194 # make test factory 202 # make test factory
195 - logger.info('Running test factory...') 203 + logger.info("Running test factory...")
196 try: 204 try:
197 self._testfactory = TestFactory(testconf) 205 self._testfactory = TestFactory(testconf)
198 except TestFactoryException as exc: 206 except TestFactoryException as exc:
199 logger.error(exc) 207 logger.error(exc)
200 - raise AppException('Failed to create test factory!') from exc 208 + raise AppException("Failed to create test factory!") from exc
201 209
202 # ------------------------------------------------------------------------ 210 # ------------------------------------------------------------------------
203 async def submit_test(self, uid, ans) -> None: 211 async def submit_test(self, uid, ans) -> None:
204 - ''' 212 + """
205 Handles test submission and correction. 213 Handles test submission and correction.
206 214
207 ans is a dictionary {question_index: answer, ...} with the answers for 215 ans is a dictionary {question_index: answer, ...} with the answers for
208 the complete test. For example: {0:'hello', 1:[1,2]} 216 the complete test. For example: {0:'hello', 1:[1,2]}
209 - '''  
210 - if self._students[uid]['state'] != 'online': 217 + """
  218 + if self._students[uid]["state"] != "online":
211 logger.warning('"%s" INVALID SUBMISSION! STUDENT NOT ONLINE', uid) 219 logger.warning('"%s" INVALID SUBMISSION! STUDENT NOT ONLINE', uid)
212 return 220 return
213 221
214 # --- submit answers and correct test 222 # --- submit answers and correct test
215 logger.info('"%s" submitted %d answers', uid, len(ans)) 223 logger.info('"%s" submitted %d answers', uid, len(ans))
216 - test = self._students[uid]['test'] 224 + test = self._students[uid]["test"]
217 test.submit(ans) 225 test.submit(ans)
218 226
219 - if test['autocorrect']: 227 + if test["autocorrect"]:
220 await test.correct_async() 228 await test.correct_async()
221 - logger.info('"%s" grade = %g points', uid, test['grade']) 229 + logger.info('"%s" grade = %g points', uid, test["grade"])
222 230
223 # --- save test in JSON format 231 # --- save test in JSON format
224 fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json' 232 fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json'
225 - fpath = os.path.join(test['answers_dir'], fname) 233 + fpath = os.path.join(test["answers_dir"], fname)
226 test.save_json(fpath) 234 test.save_json(fpath)
227 logger.info('"%s" saved JSON', uid) 235 logger.info('"%s" saved JSON', uid)
228 236
229 # --- insert test and questions into the database 237 # --- insert test and questions into the database
230 # only corrected questions are added 238 # only corrected questions are added
231 test_row = Test( 239 test_row = Test(
232 - ref=test['ref'],  
233 - title=test['title'],  
234 - grade=test['grade'],  
235 - state=test['state'],  
236 - comment=test['comment'],  
237 - starttime=str(test['start_time']),  
238 - finishtime=str(test['finish_time']), 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"]),
239 filename=fpath, 247 filename=fpath,
240 - student_id=uid) 248 + student_id=uid,
  249 + )
241 250
242 - if test['state'] == 'CORRECTED': 251 + if test["state"] == "CORRECTED":
243 test_row.questions = [ 252 test_row.questions = [
244 Question( 253 Question(
245 number=n, 254 number=n,
246 - ref=q['ref'],  
247 - grade=q['grade'],  
248 - comment=q.get('comment', ''),  
249 - starttime=str(test['start_time']),  
250 - finishtime=str(test['finish_time']),  
251 - test_id=test['ref']  
252 - )  
253 - for n, q in enumerate(test['questions'])  
254 - ] 255 + ref=q["ref"],
  256 + grade=q["grade"],
  257 + comment=q.get("comment", ""),
  258 + starttime=str(test["start_time"]),
  259 + finishtime=str(test["finish_time"]),
  260 + test_id=test["ref"],
  261 + )
  262 + for n, q in enumerate(test["questions"])
  263 + ]
255 264
256 with Session(self._engine, future=True) as session: 265 with Session(self._engine, future=True) as session:
257 session.add(test_row) 266 session.add(test_row)
@@ -262,21 +271,23 @@ class App(): @@ -262,21 +271,23 @@ class App():
262 def _correct_tests(self) -> None: 271 def _correct_tests(self) -> None:
263 with Session(self._engine, future=True) as session: 272 with Session(self._engine, future=True) as session:
264 # Find which tests have to be corrected 273 # Find which tests have to be corrected
265 - query = select(Test) \  
266 - .where(Test.ref == self._testfactory['ref']) \  
267 - .where(Test.state == "SUBMITTED") 274 + query = (
  275 + select(Test)
  276 + .where(Test.ref == self._testfactory["ref"])
  277 + .where(Test.state == "SUBMITTED")
  278 + )
268 dbtests = session.execute(query).scalars().all() 279 dbtests = session.execute(query).scalars().all()
269 if not dbtests: 280 if not dbtests:
270 - logger.info('No tests to correct') 281 + logger.info("No tests to correct")
271 return 282 return
272 283
273 - logger.info('Correcting %d tests...', len(dbtests)) 284 + logger.info("Correcting %d tests...", len(dbtests))
274 for dbtest in dbtests: 285 for dbtest in dbtests:
275 try: 286 try:
276 with open(dbtest.filename) as file: 287 with open(dbtest.filename) as file:
277 testdict = json.load(file) 288 testdict = json.load(file)
278 except OSError: 289 except OSError:
279 - logger.error('Failed: %s', dbtest.filename) 290 + logger.error("Failed: %s", dbtest.filename)
280 continue 291 continue
281 292
282 # creates a class Test with the methods to correct it 293 # creates a class Test with the methods to correct it
@@ -284,31 +295,32 @@ class App(): @@ -284,31 +295,32 @@ class App():
284 # question_from() to produce Question() instances that can be 295 # question_from() to produce Question() instances that can be
285 # corrected. Finally the test can be corrected. 296 # corrected. Finally the test can be corrected.
286 test = TestInstance(testdict) 297 test = TestInstance(testdict)
287 - test['questions'] = [question_from(q) for q in test['questions']] 298 + test["questions"] = [question_from(q) for q in test["questions"]]
288 test.correct() 299 test.correct()
289 - logger.info(' %s: %f', test['student'], test['grade']) 300 + logger.info(" %s: %f", test["student"], test["grade"])
290 301
291 # save JSON file (overwriting the old one) 302 # save JSON file (overwriting the old one)
292 - uid = test['student'] 303 + uid = test["student"]
293 test.save_json(dbtest.filename) 304 test.save_json(dbtest.filename)
294 - logger.debug('%s saved JSON file', uid) 305 + logger.debug("%s saved JSON file", uid)
295 306
296 # update database 307 # update database
297 - dbtest.grade = test['grade']  
298 - dbtest.state = test['state'] 308 + dbtest.grade = test["grade"]
  309 + dbtest.state = test["state"]
299 dbtest.questions = [ 310 dbtest.questions = [
300 Question( 311 Question(
301 number=n, 312 number=n,
302 - ref=q['ref'],  
303 - grade=q['grade'],  
304 - comment=q.get('comment', ''),  
305 - starttime=str(test['start_time']),  
306 - finishtime=str(test['finish_time']),  
307 - test_id=test['ref']  
308 - ) for n, q in enumerate(test['questions'])  
309 - ] 313 + ref=q["ref"],
  314 + grade=q["grade"],
  315 + comment=q.get("comment", ""),
  316 + starttime=str(test["start_time"]),
  317 + finishtime=str(test["finish_time"]),
  318 + test_id=test["ref"],
  319 + )
  320 + for n, q in enumerate(test["questions"])
  321 + ]
310 session.commit() 322 session.commit()
311 - logger.info('Database updated') 323 + logger.info("Database updated")
312 324
313 # ------------------------------------------------------------------------ 325 # ------------------------------------------------------------------------
314 # def giveup_test(self, uid): 326 # def giveup_test(self, uid):
@@ -340,176 +352,196 @@ class App(): @@ -340,176 +352,196 @@ class App():
340 352
341 # ------------------------------------------------------------------------ 353 # ------------------------------------------------------------------------
342 def register_event(self, uid, cmd, value): 354 def register_event(self, uid, cmd, value):
343 - '''handles browser events the occur during the test'''  
344 - if cmd == 'focus': 355 + """handles browser events the occur during the test"""
  356 + if cmd == "focus":
345 if value: 357 if value:
346 self._focus_student(uid) 358 self._focus_student(uid)
347 else: 359 else:
348 self._unfocus_student(uid) 360 self._unfocus_student(uid)
349 - elif cmd == 'size': 361 + elif cmd == "size":
350 self._set_screen_area(uid, value) 362 self._set_screen_area(uid, value)
  363 + elif cmd == "update_answer":
  364 + print(cmd, value) # FIXME
  365 + elif cmd == "lint":
  366 + print(cmd, value) # FIXME
351 367
352 # ======================================================================== 368 # ========================================================================
353 # GETTERS 369 # GETTERS
354 # ======================================================================== 370 # ========================================================================
355 def get_test(self, uid: str) -> Optional[dict]: 371 def get_test(self, uid: str) -> Optional[dict]:
356 - '''return student test'''  
357 - return self._students[uid]['test'] 372 + """return student test"""
  373 + return self._students[uid]["test"]
358 374
359 # ------------------------------------------------------------------------ 375 # ------------------------------------------------------------------------
360 def get_name(self, uid: str) -> str: 376 def get_name(self, uid: str) -> str:
361 - '''return name of student'''  
362 - return self._students[uid]['name'] 377 + """return name of student"""
  378 + return self._students[uid]["name"]
363 379
364 # ------------------------------------------------------------------------ 380 # ------------------------------------------------------------------------
365 def get_test_config(self) -> dict: 381 def get_test_config(self) -> dict:
366 - '''return brief test configuration to use as header in /admin'''  
367 - return {'title': self._testfactory['title'],  
368 - 'ref': self._testfactory['ref'],  
369 - 'filename': self._testfactory['testfile'],  
370 - 'database': self._testfactory['database'],  
371 - 'answers_dir': self._testfactory['answers_dir']  
372 - } 382 + """return brief test configuration to use as header in /admin"""
  383 + return {
  384 + "title": self._testfactory["title"],
  385 + "ref": self._testfactory["ref"],
  386 + "filename": self._testfactory["testfile"],
  387 + "database": self._testfactory["database"],
  388 + "answers_dir": self._testfactory["answers_dir"],
  389 + }
373 390
374 # ------------------------------------------------------------------------ 391 # ------------------------------------------------------------------------
375 def get_grades_csv(self): 392 def get_grades_csv(self):
376 - '''generates a CSV with the grades of the test currently running'''  
377 - test_ref = self._testfactory['ref'] 393 + """generates a CSV with the grades of the test currently running"""
  394 + test_ref = self._testfactory["ref"]
378 with Session(self._engine, future=True) as session: 395 with Session(self._engine, future=True) as session:
379 - query = select(Test.student_id, Test.grade,  
380 - Test.starttime, Test.finishtime)\  
381 - .where(Test.ref == test_ref)\  
382 - .order_by(Test.student_id) 396 + query = (
  397 + select(Test.student_id, Test.grade, Test.starttime, Test.finishtime)
  398 + .where(Test.ref == test_ref)
  399 + .order_by(Test.student_id)
  400 + )
383 tests = session.execute(query).all() 401 tests = session.execute(query).all()
384 if not tests: 402 if not tests:
385 - logger.warning('Empty CSV: there are no tests!')  
386 - return test_ref, '' 403 + logger.warning("Empty CSV: there are no tests!")
  404 + return test_ref, ""
387 405
388 csvstr = io.StringIO() 406 csvstr = io.StringIO()
389 - writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL)  
390 - writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) 407 + writer = csv.writer(csvstr, delimiter=";", quoting=csv.QUOTE_ALL)
  408 + writer.writerow(("Aluno", "Nota", "Início", "Fim"))
391 writer.writerows(tests) 409 writer.writerows(tests)
392 return test_ref, csvstr.getvalue() 410 return test_ref, csvstr.getvalue()
393 411
394 # ------------------------------------------------------------------------ 412 # ------------------------------------------------------------------------
395 def get_detailed_grades_csv(self): 413 def get_detailed_grades_csv(self):
396 - '''generates a CSV with the grades of the test'''  
397 - test_ref = self._testfactory['ref'] 414 + """generates a CSV with the grades of the test"""
  415 + test_ref = self._testfactory["ref"]
398 with Session(self._engine, future=True) as session: 416 with Session(self._engine, future=True) as session:
399 - query = select(Test.id, Test.student_id, Test.starttime,  
400 - Question.number, Question.grade)\  
401 - .join(Question)\  
402 - .where(Test.ref == test_ref) 417 + query = (
  418 + select(
  419 + Test.id,
  420 + Test.student_id,
  421 + Test.starttime,
  422 + Question.number,
  423 + Question.grade,
  424 + )
  425 + .join(Question)
  426 + .where(Test.ref == test_ref)
  427 + )
403 questions = session.execute(query).all() 428 questions = session.execute(query).all()
404 429
405 - cols = ['Aluno', 'Início']  
406 - tests = {} # {test_id: {student_id, starttime, 0: grade, ...}} 430 + cols = ["Aluno", "Início"]
  431 + tests = {} # {test_id: {student_id, starttime, 0: grade, ...}}
407 for test_id, student_id, starttime, num, grade in questions: 432 for test_id, student_id, starttime, num, grade in questions:
408 - default_test_id = {'Aluno': student_id, 'Início': starttime} 433 + default_test_id = {"Aluno": student_id, "Início": starttime}
409 tests.setdefault(test_id, default_test_id)[num] = grade 434 tests.setdefault(test_id, default_test_id)[num] = grade
410 if num not in cols: 435 if num not in cols:
411 cols.append(num) 436 cols.append(num)
412 437
413 if not tests: 438 if not tests:
414 - logger.warning('Empty CSV: there are no tests!')  
415 - return test_ref, '' 439 + logger.warning("Empty CSV: there are no tests!")
  440 + return test_ref, ""
416 441
417 csvstr = io.StringIO() 442 csvstr = io.StringIO()
418 - writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None,  
419 - delimiter=';', quoting=csv.QUOTE_ALL) 443 + writer = csv.DictWriter(
  444 + csvstr, fieldnames=cols, restval=None, delimiter=";", quoting=csv.QUOTE_ALL
  445 + )
420 writer.writeheader() 446 writer.writeheader()
421 writer.writerows(tests.values()) 447 writer.writerows(tests.values())
422 return test_ref, csvstr.getvalue() 448 return test_ref, csvstr.getvalue()
423 449
424 # ------------------------------------------------------------------------ 450 # ------------------------------------------------------------------------
425 def get_json_filename_of_test(self, test_id): 451 def get_json_filename_of_test(self, test_id):
426 - '''get JSON filename from database given the test_id''' 452 + """get JSON filename from database given the test_id"""
427 with Session(self._engine, future=True) as session: 453 with Session(self._engine, future=True) as session:
428 query = select(Test.filename).where(Test.id == test_id) 454 query = select(Test.filename).where(Test.id == test_id)
429 return session.execute(query).scalar() 455 return session.execute(query).scalar()
430 456
431 # ------------------------------------------------------------------------ 457 # ------------------------------------------------------------------------
432 def get_grades(self, uid, ref): 458 def get_grades(self, uid, ref):
433 - '''get grades of student for a given testid''' 459 + """get grades of student for a given testid"""
434 with Session(self._engine, future=True) as session: 460 with Session(self._engine, future=True) as session:
435 - query = select(Test.grade, Test.finishtime, Test.id)\  
436 - .where(Test.student_id == uid)\  
437 - .where(Test.ref == ref) 461 + query = (
  462 + select(Test.grade, Test.finishtime, Test.id)
  463 + .where(Test.student_id == uid)
  464 + .where(Test.ref == ref)
  465 + )
438 grades = session.execute(query).all() 466 grades = session.execute(query).all()
439 return [tuple(grade) for grade in grades] 467 return [tuple(grade) for grade in grades]
440 468
441 # ------------------------------------------------------------------------ 469 # ------------------------------------------------------------------------
442 def get_students_state(self) -> list: 470 def get_students_state(self) -> list:
443 - '''get list of states of every student to show in /admin page'''  
444 - return [{ 'uid': uid,  
445 - 'name': student['name'],  
446 - 'allowed': student['state'] == 'allowed',  
447 - 'online': student['state'] == 'online',  
448 - 'start_time': student.get('test', {}).get('start_time', ''),  
449 - 'unfocus': student.get('unfocus', False),  
450 - 'area': student.get('area', 1.0),  
451 - 'grades': self.get_grades(uid, self._testfactory['ref']) }  
452 - for uid, student in self._students.items()] 471 + """get list of states of every student to show in /admin page"""
  472 + return [
  473 + {
  474 + "uid": uid,
  475 + "name": student["name"],
  476 + "allowed": student["state"] == "allowed",
  477 + "online": student["state"] == "online",
  478 + "start_time": student.get("test", {}).get("start_time", ""),
  479 + "unfocus": student.get("unfocus", False),
  480 + "area": student.get("area", 1.0),
  481 + "grades": self.get_grades(uid, self._testfactory["ref"]),
  482 + }
  483 + for uid, student in self._students.items()
  484 + ]
453 485
454 # ======================================================================== 486 # ========================================================================
455 # SETTERS 487 # SETTERS
456 # ======================================================================== 488 # ========================================================================
457 def allow_student(self, uid: str) -> None: 489 def allow_student(self, uid: str) -> None:
458 - '''allow a single student to login'''  
459 - self._students[uid]['state'] = 'allowed' 490 + """allow a single student to login"""
  491 + self._students[uid]["state"] = "allowed"
460 logger.info('"%s" allowed to login', uid) 492 logger.info('"%s" allowed to login', uid)
461 493
462 # ------------------------------------------------------------------------ 494 # ------------------------------------------------------------------------
463 def deny_student(self, uid: str) -> None: 495 def deny_student(self, uid: str) -> None:
464 - '''deny a single student to login''' 496 + """deny a single student to login"""
465 student = self._students[uid] 497 student = self._students[uid]
466 - if student['state'] == 'allowed':  
467 - student['state'] = 'offline' 498 + if student["state"] == "allowed":
  499 + student["state"] = "offline"
468 logger.info('"%s" denied to login', uid) 500 logger.info('"%s" denied to login', uid)
469 501
470 # ------------------------------------------------------------------------ 502 # ------------------------------------------------------------------------
471 def allow_all_students(self) -> None: 503 def allow_all_students(self) -> None:
472 - '''allow all students to login''' 504 + """allow all students to login"""
473 for student in self._students.values(): 505 for student in self._students.values():
474 - student['state'] = 'allowed'  
475 - logger.info('Allowed %d students', len(self._students)) 506 + student["state"] = "allowed"
  507 + logger.info("Allowed %d students", len(self._students))
476 508
477 # ------------------------------------------------------------------------ 509 # ------------------------------------------------------------------------
478 def deny_all_students(self) -> None: 510 def deny_all_students(self) -> None:
479 - '''deny all students to login'''  
480 - logger.info('Denying all students...') 511 + """deny all students to login"""
  512 + logger.info("Denying all students...")
481 for student in self._students.values(): 513 for student in self._students.values():
482 - if student['state'] == 'allowed':  
483 - student['state'] = 'offline' 514 + if student["state"] == "allowed":
  515 + student["state"] = "offline"
484 516
485 # ------------------------------------------------------------------------ 517 # ------------------------------------------------------------------------
486 async def insert_new_student(self, uid: str, name: str) -> None: 518 async def insert_new_student(self, uid: str, name: str) -> None:
487 - '''insert new student into the database''' 519 + """insert new student into the database"""
488 with Session(self._engine, future=True) as session: 520 with Session(self._engine, future=True) as session:
489 try: 521 try:
490 - session.add(Student(id=uid, name=name, password='')) 522 + session.add(Student(id=uid, name=name, password=""))
491 session.commit() 523 session.commit()
492 except IntegrityError: 524 except IntegrityError:
493 logger.warning('"%s" already exists!', uid) 525 logger.warning('"%s" already exists!', uid)
494 session.rollback() 526 session.rollback()
495 return 527 return
496 - logger.info('New student added: %s %s', uid, name) 528 + logger.info("New student added: %s %s", uid, name)
497 self._students[uid] = { 529 self._students[uid] = {
498 - 'name': name,  
499 - 'state': 'offline',  
500 - 'test': await self._testfactory.generate(),  
501 - } 530 + "name": name,
  531 + "state": "offline",
  532 + "test": await self._testfactory.generate(),
  533 + }
502 534
503 # ------------------------------------------------------------------------ 535 # ------------------------------------------------------------------------
504 def allow_from_list(self, filename: str) -> None: 536 def allow_from_list(self, filename: str) -> None:
505 - '''allow students listed in text file (one number per line)''' 537 + """allow students listed in text file (one number per line)"""
506 # parse list of students to allow (one number per line) 538 # parse list of students to allow (one number per line)
507 try: 539 try:
508 - with open(filename, 'r', encoding='utf-8') as file: 540 + with open(filename, "r", encoding="utf-8") as file:
509 allowed = {line.strip() for line in file} 541 allowed = {line.strip() for line in file}
510 - allowed.discard('') 542 + allowed.discard("")
511 except OSError as exc: 543 except OSError as exc:
512 - error_msg = f'Cannot read file {filename}' 544 + error_msg = f"Cannot read file {filename}"
513 logger.critical(error_msg) 545 logger.critical(error_msg)
514 raise AppException(error_msg) from exc 546 raise AppException(error_msg) from exc
515 547
@@ -522,27 +554,34 @@ class App(): @@ -522,27 +554,34 @@ class App():
522 logger.warning('Allowed student "%s" does not exist!', uid) 554 logger.warning('Allowed student "%s" does not exist!', uid)
523 missing += 1 555 missing += 1
524 556
525 - logger.info('Allowed %d students', len(allowed)-missing) 557 + logger.info("Allowed %d students", len(allowed) - missing)
526 if missing: 558 if missing:
527 - logger.warning(' %d missing!', missing) 559 + logger.warning(" %d missing!", missing)
528 560
529 # ------------------------------------------------------------------------ 561 # ------------------------------------------------------------------------
530 def _focus_student(self, uid): 562 def _focus_student(self, uid):
531 - '''set student in focus state'''  
532 - self._students[uid]['unfocus'] = False 563 + """set student in focus state"""
  564 + self._students[uid]["unfocus"] = False
533 logger.info('"%s" focus', uid) 565 logger.info('"%s" focus', uid)
534 566
535 # ------------------------------------------------------------------------ 567 # ------------------------------------------------------------------------
536 def _unfocus_student(self, uid): 568 def _unfocus_student(self, uid):
537 - '''set student in unfocus state'''  
538 - self._students[uid]['unfocus'] = True 569 + """set student in unfocus state"""
  570 + self._students[uid]["unfocus"] = True
539 logger.info('"%s" unfocus', uid) 571 logger.info('"%s" unfocus', uid)
540 572
541 # ------------------------------------------------------------------------ 573 # ------------------------------------------------------------------------
542 def _set_screen_area(self, uid, sizes): 574 def _set_screen_area(self, uid, sizes):
543 - '''set current browser area as detected in resize event''' 575 + """set current browser area as detected in resize event"""
544 scr_y, scr_x, win_y, win_x = sizes 576 scr_y, scr_x, win_y, win_x = sizes
545 area = win_x * win_y / (scr_x * scr_y) * 100 577 area = win_x * win_y / (scr_x * scr_y) * 100
546 - self._students[uid]['area'] = area  
547 - logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d',  
548 - uid, area, win_x, win_y, scr_x, scr_y) 578 + self._students[uid]["area"] = area
  579 + logger.info(
  580 + '"%s" area=%g%%, window=%dx%d, screen=%dx%d',
  581 + uid,
  582 + area,
  583 + win_x,
  584 + win_y,
  585 + scr_x,
  586 + scr_y,
  587 + )
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
@@ -23,77 +23,84 @@ from .models import Base, Student @@ -23,77 +23,84 @@ from .models import Base, Student
23 23
24 # ============================================================================ 24 # ============================================================================
25 def parse_commandline_arguments(): 25 def parse_commandline_arguments():
26 - '''Parse command line options''' 26 + """Parse command line options"""
27 27
28 parser = argparse.ArgumentParser( 28 parser = argparse.ArgumentParser(
29 formatter_class=argparse.ArgumentDefaultsHelpFormatter, 29 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
30 - description='Insert new users into a database. Users can be imported '  
31 - 'from CSV files in the SIIUE format or defined in the '  
32 - 'command line. If the database does not exist, a new one '  
33 - 'is created.')  
34 -  
35 - parser.add_argument('csvfile',  
36 - nargs='*',  
37 - type=str,  
38 - default='',  
39 - help='CSV file to import (SIIUE)')  
40 -  
41 - parser.add_argument('--db',  
42 - default='students.db',  
43 - type=str,  
44 - help='database file')  
45 -  
46 - parser.add_argument('-A', '--admin',  
47 - action='store_true',  
48 - help='insert admin user 0 "Admin"')  
49 -  
50 - parser.add_argument('-a', '--add',  
51 - nargs=2,  
52 - action='append',  
53 - metavar=('uid', 'name'),  
54 - help='add new user id and name')  
55 -  
56 - parser.add_argument('-u', '--update',  
57 - nargs='+',  
58 - metavar='uid',  
59 - default=[],  
60 - help='list of users whose password is to be updated')  
61 -  
62 - parser.add_argument('-U', '--update-all',  
63 - action='store_true',  
64 - help='all except admin will have the password updated')  
65 -  
66 - parser.add_argument('--pw',  
67 - default=None,  
68 - type=str,  
69 - help='password for new or updated users')  
70 -  
71 - parser.add_argument('-V', '--verbose',  
72 - action='store_true',  
73 - help='show all students in database') 30 + description="Insert new users into a database. Users can be imported "
  31 + "from CSV files in the SIIUE format or defined in the "
  32 + "command line. If the database does not exist, a new one "
  33 + "is created.",
  34 + )
  35 +
  36 + parser.add_argument(
  37 + "csvfile", nargs="*", type=str, default="", help="CSV file to import (SIIUE)"
  38 + )
  39 +
  40 + parser.add_argument("--db", default="students.db", type=str, help="database file")
  41 +
  42 + parser.add_argument(
  43 + "-A", "--admin", action="store_true", help='insert admin user 0 "Admin"'
  44 + )
  45 +
  46 + parser.add_argument(
  47 + "-a",
  48 + "--add",
  49 + nargs=2,
  50 + action="append",
  51 + metavar=("uid", "name"),
  52 + help="add new user id and name",
  53 + )
  54 +
  55 + parser.add_argument(
  56 + "-u",
  57 + "--update",
  58 + nargs="+",
  59 + metavar="uid",
  60 + default=[],
  61 + help="list of users whose password is to be updated",
  62 + )
  63 +
  64 + parser.add_argument(
  65 + "-U",
  66 + "--update-all",
  67 + action="store_true",
  68 + help="all except admin will have the password updated",
  69 + )
  70 +
  71 + parser.add_argument(
  72 + "--pw", default=None, type=str, help="password for new or updated users"
  73 + )
  74 +
  75 + parser.add_argument(
  76 + "-V", "--verbose", action="store_true", help="show all students in database"
  77 + )
74 78
75 return parser.parse_args() 79 return parser.parse_args()
76 80
77 81
78 # ============================================================================ 82 # ============================================================================
79 def get_students_from_csv(filename): 83 def get_students_from_csv(filename):
80 - ''' 84 + """
81 SIIUE names have alien strings like "(TE)" and are sometimes capitalized 85 SIIUE names have alien strings like "(TE)" and are sometimes capitalized
82 We remove them so that students dont keep asking what it means 86 We remove them so that students dont keep asking what it means
83 - ''' 87 + """
84 csv_settings = { 88 csv_settings = {
85 - 'delimiter': ';',  
86 - 'quotechar': '"',  
87 - 'skipinitialspace': True,  
88 - } 89 + "delimiter": ";",
  90 + "quotechar": '"',
  91 + "skipinitialspace": True,
  92 + }
89 93
90 try: 94 try:
91 - with open(filename, encoding='iso-8859-1') as file: 95 + with open(filename, encoding="iso-8859-1") as file:
92 csvreader = csv.DictReader(file, **csv_settings) 96 csvreader = csv.DictReader(file, **csv_settings)
93 - students = [{  
94 - 'uid': s['N.º'],  
95 - 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())  
96 - } for s in csvreader] 97 + students = [
  98 + {
  99 + "uid": s["N.º"],
  100 + "name": capwords(re.sub(r"\(.*\)", "", s["Nome"]).strip()),
  101 + }
  102 + for s in csvreader
  103 + ]
97 except OSError: 104 except OSError:
98 print(f'!!! Error reading file "{filename}" !!!') 105 print(f'!!! Error reading file "{filename}" !!!')
99 students = [] 106 students = []
@@ -106,63 +113,63 @@ def get_students_from_csv(filename): @@ -106,63 +113,63 @@ def get_students_from_csv(filename):
106 113
107 # ============================================================================ 114 # ============================================================================
108 def hashpw(student, password=None) -> None: 115 def hashpw(student, password=None) -> None:
109 - '''replace password by hash for a single student'''  
110 - print('.', end='', flush=True) 116 + """replace password by hash for a single student"""
  117 + print(".", end="", flush=True)
111 if password is None: 118 if password is None:
112 - student['pw'] = '' 119 + student["pw"] = ""
113 else: 120 else:
114 - student['pw'] = bcrypt.hashpw(password.encode('utf-8'),  
115 - bcrypt.gensalt()) 121 + student["pw"] = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt())
116 122
117 123
118 # ============================================================================ 124 # ============================================================================
119 def insert_students_into_db(session, students) -> None: 125 def insert_students_into_db(session, students) -> None:
120 - '''insert list of students into the database''' 126 + """insert list of students into the database"""
121 try: 127 try:
122 - session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw'])  
123 - for s in students]) 128 + session.add_all(
  129 + [Student(id=s["uid"], name=s["name"], password=s["pw"]) for s in students]
  130 + )
124 session.commit() 131 session.commit()
125 132
126 except IntegrityError: 133 except IntegrityError:
127 - print('!!! Integrity error. Users already in database. Aborted !!!\n') 134 + print("!!! Integrity error. Users already in database. Aborted !!!\n")
128 session.rollback() 135 session.rollback()
129 136
130 137
131 # ============================================================================ 138 # ============================================================================
132 def show_students_in_database(session, verbose=False): 139 def show_students_in_database(session, verbose=False):
133 - '''get students from database''' 140 + """get students from database"""
134 users = session.execute(select(Student)).scalars().all() 141 users = session.execute(select(Student)).scalars().all()
135 # users = session.query(Student).all() 142 # users = session.query(Student).all()
136 total = len(users) 143 total = len(users)
137 144
138 - print('Registered users:') 145 + print("Registered users:")
139 if total == 0: 146 if total == 0:
140 - print(' -- none --') 147 + print(" -- none --")
141 else: 148 else:
142 - users.sort(key=lambda u: f'{u.id:>12}') # sort by number 149 + users.sort(key=lambda u: f"{u.id:>12}") # sort by number
143 if verbose: 150 if verbose:
144 for user in users: 151 for user in users:
145 - print(f'{user.id:>12} {user.name}') 152 + print(f"{user.id:>12} {user.name}")
146 else: 153 else:
147 - print(f'{users[0].id:>12} {users[0].name}') 154 + print(f"{users[0].id:>12} {users[0].name}")
148 if total > 1: 155 if total > 1:
149 - print(f'{users[1].id:>12} {users[1].name}') 156 + print(f"{users[1].id:>12} {users[1].name}")
150 if total > 3: 157 if total > 3:
151 - print(' | |') 158 + print(" | |")
152 if total > 2: 159 if total > 2:
153 - print(f'{users[-1].id:>12} {users[-1].name}')  
154 - print(f'Total: {total}.') 160 + print(f"{users[-1].id:>12} {users[-1].name}")
  161 + print(f"Total: {total}.")
155 162
156 163
157 # ============================================================================ 164 # ============================================================================
158 def main(): 165 def main():
159 - '''insert, update, show students from database''' 166 + """insert, update, show students from database"""
160 167
161 args = parse_commandline_arguments() 168 args = parse_commandline_arguments()
162 169
163 # --- database 170 # --- database
164 - print(f'Database: {args.db}')  
165 - engine = create_engine(f'sqlite:///{args.db}', echo=False, future=True) 171 + print(f"Database: {args.db}")
  172 + engine = create_engine(f"sqlite:///{args.db}", echo=False, future=True)
166 Base.metadata.create_all(engine) # Criates schema if needed 173 Base.metadata.create_all(engine) # Criates schema if needed
167 session = Session(engine, future=True) 174 session = Session(engine, future=True)
168 175
@@ -170,49 +177,48 @@ def main(): @@ -170,49 +177,48 @@ def main():
170 students = [] 177 students = []
171 178
172 if args.admin: 179 if args.admin:
173 - print('Adding user: 0, Admin.')  
174 - students.append({'uid': '0', 'name': 'Admin'}) 180 + print("Adding user: 0, Admin.")
  181 + students.append({"uid": "0", "name": "Admin"})
175 182
176 for csvfile in args.csvfile: 183 for csvfile in args.csvfile:
177 - print('Adding users from:', csvfile) 184 + print("Adding users from:", csvfile)
178 students.extend(get_students_from_csv(csvfile)) 185 students.extend(get_students_from_csv(csvfile))
179 186
180 if args.add: 187 if args.add:
181 for uid, name in args.add: 188 for uid, name in args.add:
182 - print(f'Adding user: {uid}, {name}.')  
183 - students.append({'uid': uid, 'name': name}) 189 + print(f"Adding user: {uid}, {name}.")
  190 + students.append({"uid": uid, "name": name})
184 191
185 # --- insert new students 192 # --- insert new students
186 if students: 193 if students:
187 - print('Generating password hashes', end='') 194 + print("Generating password hashes", end="")
188 with ThreadPoolExecutor() as executor: # hashing 195 with ThreadPoolExecutor() as executor: # hashing
189 executor.map(lambda s: hashpw(s, args.pw), students) 196 executor.map(lambda s: hashpw(s, args.pw), students)
190 - print(f'\nInserting {len(students)}') 197 + print(f"\nInserting {len(students)}")
191 insert_students_into_db(session, students) 198 insert_students_into_db(session, students)
192 199
193 # --- update all students 200 # --- update all students
194 if args.update_all: 201 if args.update_all:
195 - all_students = session.execute(  
196 - select(Student).where(Student.id != '0')  
197 - ).scalars().all() 202 + all_students = (
  203 + session.execute(select(Student).where(Student.id != "0")).scalars().all()
  204 + )
198 205
199 - print(f'Updating password of {len(all_students)} users', end='') 206 + print(f"Updating password of {len(all_students)} users", end="")
200 for student in all_students: 207 for student in all_students:
201 - password = (args.pw or student.id).encode('utf-8') 208 + password = (args.pw or student.id).encode("utf-8")
202 student.password = bcrypt.hashpw(password, bcrypt.gensalt()) 209 student.password = bcrypt.hashpw(password, bcrypt.gensalt())
203 - print('.', end='', flush=True) 210 + print(".", end="", flush=True)
204 print() 211 print()
205 session.commit() 212 session.commit()
206 213
207 # --- update some students 214 # --- update some students
208 else: 215 else:
209 for student_id in args.update: 216 for student_id in args.update:
210 - print(f'Updating password of {student_id}') 217 + print(f"Updating password of {student_id}")
211 student = session.execute( 218 student = session.execute(
212 - select(Student).  
213 - where(Student.id == student_id) 219 + select(Student).where(Student.id == student_id)
214 ).scalar_one() 220 ).scalar_one()
215 - new_password = (args.pw or student_id).encode('utf-8') 221 + new_password = (args.pw or student_id).encode("utf-8")
216 student.password = bcrypt.hashpw(new_password, bcrypt.gensalt()) 222 student.password = bcrypt.hashpw(new_password, bcrypt.gensalt())
217 session.commit() 223 session.commit()
218 224
@@ -222,5 +228,5 @@ def main(): @@ -222,5 +228,5 @@ def main():
222 228
223 229
224 # ============================================================================ 230 # ============================================================================
225 -if __name__ == '__main__': 231 +if __name__ == "__main__":
226 main() 232 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 Any 6 from typing import Any
7 7
@@ -16,26 +16,30 @@ Base: Any = declarative_base() @@ -16,26 +16,30 @@ Base: Any = declarative_base()
16 16
17 # ---------------------------------------------------------------------------- 17 # ----------------------------------------------------------------------------
18 class Student(Base): 18 class Student(Base):
19 - '''Student table'''  
20 - __tablename__ = 'students' 19 + """Student table"""
  20 +
  21 + __tablename__ = "students"
21 id = Column(String, primary_key=True) 22 id = Column(String, primary_key=True)
22 name = Column(String) 23 name = Column(String)
23 password = Column(String) 24 password = Column(String)
24 25
25 # --- 26 # ---
26 - tests = relationship('Test', back_populates='student') 27 + tests = relationship("Test", back_populates="student")
27 28
28 def __repr__(self): 29 def __repr__(self):
29 - return (f'Student('  
30 - f'id={self.id!r}, '  
31 - f'name={self.name!r}, '  
32 - f'password={self.password!r})') 30 + return (
  31 + f"Student("
  32 + f"id={self.id!r}, "
  33 + f"name={self.name!r}, "
  34 + f"password={self.password!r})"
  35 + )
33 36
34 37
35 # ---------------------------------------------------------------------------- 38 # ----------------------------------------------------------------------------
36 class Test(Base): 39 class Test(Base):
37 - '''Test table'''  
38 - __tablename__ = 'tests' 40 + """Test table"""
  41 +
  42 + __tablename__ = "tests"
39 id = Column(Integer, primary_key=True) # auto_increment 43 id = Column(Integer, primary_key=True) # auto_increment
40 ref = Column(String) 44 ref = Column(String)
41 title = Column(String) 45 title = Column(String)
@@ -45,49 +49,54 @@ class Test(Base): @@ -45,49 +49,54 @@ class Test(Base):
45 starttime = Column(String) 49 starttime = Column(String)
46 finishtime = Column(String) 50 finishtime = Column(String)
47 filename = Column(String) 51 filename = Column(String)
48 - student_id = Column(String, ForeignKey('students.id')) 52 + student_id = Column(String, ForeignKey("students.id"))
49 53
50 # --- 54 # ---
51 - student = relationship('Student', back_populates='tests')  
52 - questions = relationship('Question', back_populates='test') 55 + student = relationship("Student", back_populates="tests")
  56 + questions = relationship("Question", back_populates="test")
53 57
54 def __repr__(self): 58 def __repr__(self):
55 - return (f'Test('  
56 - f'id={self.id!r}, '  
57 - f'ref={self.ref!r}, '  
58 - f'title={self.title!r}, '  
59 - f'grade={self.grade!r}, '  
60 - f'state={self.state!r}, '  
61 - f'comment={self.comment!r}, '  
62 - f'starttime={self.starttime!r}, '  
63 - f'finishtime={self.finishtime!r}, '  
64 - f'filename={self.filename!r}, '  
65 - f'student_id={self.student_id!r})') 59 + return (
  60 + f"Test("
  61 + f"id={self.id!r}, "
  62 + f"ref={self.ref!r}, "
  63 + f"title={self.title!r}, "
  64 + f"grade={self.grade!r}, "
  65 + f"state={self.state!r}, "
  66 + f"comment={self.comment!r}, "
  67 + f"starttime={self.starttime!r}, "
  68 + f"finishtime={self.finishtime!r}, "
  69 + f"filename={self.filename!r}, "
  70 + f"student_id={self.student_id!r})"
  71 + )
66 72
67 73
68 # --------------------------------------------------------------------------- 74 # ---------------------------------------------------------------------------
69 class Question(Base): 75 class Question(Base):
70 - '''Question table'''  
71 - __tablename__ = 'questions' 76 + """Question table"""
  77 +
  78 + __tablename__ = "questions"
72 id = Column(Integer, primary_key=True) # auto_increment 79 id = Column(Integer, primary_key=True) # auto_increment
73 - number = Column(Integer) # question number (ref may be not be unique) 80 + number = Column(Integer) # question number (ref may be not be unique)
74 ref = Column(String) 81 ref = Column(String)
75 grade = Column(Float) 82 grade = Column(Float)
76 comment = Column(String) 83 comment = Column(String)
77 starttime = Column(String) 84 starttime = Column(String)
78 finishtime = Column(String) 85 finishtime = Column(String)
79 - test_id = Column(String, ForeignKey('tests.id')) 86 + test_id = Column(String, ForeignKey("tests.id"))
80 87
81 # --- 88 # ---
82 - test = relationship('Test', back_populates='questions') 89 + test = relationship("Test", back_populates="questions")
83 90
84 def __repr__(self): 91 def __repr__(self):
85 - return (f'Question('  
86 - f'id={self.id!r}, '  
87 - f'number={self.number!r}, '  
88 - f'ref={self.ref!r}, '  
89 - f'grade={self.grade!r}, '  
90 - f'comment={self.comment!r}, '  
91 - f'starttime={self.starttime!r}, '  
92 - f'finishtime={self.finishtime!r}, '  
93 - f'test_id={self.test_id!r})') 92 + return (
  93 + f"Question("
  94 + f"id={self.id!r}, "
  95 + f"number={self.number!r}, "
  96 + f"ref={self.ref!r}, "
  97 + f"grade={self.grade!r}, "
  98 + f"comment={self.comment!r}, "
  99 + f"starttime={self.starttime!r}, "
  100 + f"finishtime={self.finishtime!r}, "
  101 + f"test_id={self.test_id!r})"
  102 + )
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 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
3 -''' 3 +"""
4 Handles the web, http & html part of the application interface. 4 Handles the web, http & html part of the application interface.
5 Uses the tornadoweb framework. 5 Uses the tornadoweb framework.
6 -''' 6 +"""
7 7
8 # python standard library 8 # python standard library
9 import asyncio 9 import asyncio
@@ -35,29 +35,30 @@ logger = logging.getLogger(__name__) @@ -35,29 +35,30 @@ logger = logging.getLogger(__name__)
35 35
36 # ---------------------------------------------------------------------------- 36 # ----------------------------------------------------------------------------
37 class WebApplication(tornado.web.Application): 37 class WebApplication(tornado.web.Application):
38 - ''' 38 + """
39 Web Application. Routes to handler classes. 39 Web Application. Routes to handler classes.
40 - ''' 40 + """
  41 +
41 def __init__(self, testapp, debug=False): 42 def __init__(self, testapp, debug=False):
42 handlers = [ 43 handlers = [
43 - (r'/login', LoginHandler),  
44 - (r'/logout', LogoutHandler),  
45 - (r'/review', ReviewHandler),  
46 - (r'/admin', AdminHandler),  
47 - (r'/file', FileHandler),  
48 - (r'/adminwebservice', AdminWebservice),  
49 - (r'/studentwebservice', StudentWebservice),  
50 - (r'/', RootHandler), 44 + (r"/login", LoginHandler),
  45 + (r"/logout", LogoutHandler),
  46 + (r"/review", ReviewHandler),
  47 + (r"/admin", AdminHandler),
  48 + (r"/file", FileHandler),
  49 + (r"/adminwebservice", AdminWebservice),
  50 + (r"/studentwebservice", StudentWebservice),
  51 + (r"/", RootHandler),
51 ] 52 ]
52 53
53 settings = { 54 settings = {
54 - 'template_path': path.join(path.dirname(__file__), 'templates'),  
55 - 'static_path': path.join(path.dirname(__file__), 'static'),  
56 - 'static_url_prefix': '/static/',  
57 - 'xsrf_cookies': True,  
58 - 'cookie_secret': base64.b64encode(uuid.uuid4().bytes),  
59 - 'login_url': '/login',  
60 - 'debug': debug, 55 + "template_path": path.join(path.dirname(__file__), "templates"),
  56 + "static_path": path.join(path.dirname(__file__), "static"),
  57 + "static_url_prefix": "/static/",
  58 + "xsrf_cookies": True,
  59 + "cookie_secret": base64.b64encode(uuid.uuid4().bytes),
  60 + "login_url": "/login",
  61 + "debug": debug,
61 } 62 }
62 super().__init__(handlers, **settings) 63 super().__init__(handlers, **settings)
63 self.testapp = testapp 64 self.testapp = testapp
@@ -65,32 +66,34 @@ class WebApplication(tornado.web.Application): @@ -65,32 +66,34 @@ class WebApplication(tornado.web.Application):
65 66
66 # ---------------------------------------------------------------------------- 67 # ----------------------------------------------------------------------------
67 def admin_only(func): 68 def admin_only(func):
68 - ''' 69 + """
69 Decorator to restrict access to the administrator: 70 Decorator to restrict access to the administrator:
70 71
71 @admin_only 72 @admin_only
72 def get(self): 73 def get(self):
73 - ''' 74 + """
  75 +
74 @functools.wraps(func) 76 @functools.wraps(func)
75 async def wrapper(self, *args, **kwargs): 77 async def wrapper(self, *args, **kwargs):
76 - if self.current_user != '0': 78 + if self.current_user != "0":
77 raise tornado.web.HTTPError(403) # forbidden 79 raise tornado.web.HTTPError(403) # forbidden
78 await func(self, *args, **kwargs) 80 await func(self, *args, **kwargs)
  81 +
79 return wrapper 82 return wrapper
80 83
81 84
82 # ---------------------------------------------------------------------------- 85 # ----------------------------------------------------------------------------
83 # pylint: disable=abstract-method 86 # pylint: disable=abstract-method
84 class BaseHandler(tornado.web.RequestHandler): 87 class BaseHandler(tornado.web.RequestHandler):
85 - ''' 88 + """
86 Handlers should inherit this one instead of tornado.web.RequestHandler. 89 Handlers should inherit this one instead of tornado.web.RequestHandler.
87 It automatically gets the user cookie, which is required to identify the 90 It automatically gets the user cookie, which is required to identify the
88 user in most handlers. 91 user in most handlers.
89 - ''' 92 + """
90 93
91 @property 94 @property
92 def testapp(self): 95 def testapp(self):
93 - '''simplifies access to the application a little bit''' 96 + """simplifies access to the application a little bit"""
94 return self.application.testapp 97 return self.application.testapp
95 98
96 # @property 99 # @property
@@ -99,62 +102,62 @@ class BaseHandler(tornado.web.RequestHandler): @@ -99,62 +102,62 @@ class BaseHandler(tornado.web.RequestHandler):
99 # return self.application.testapp.debug 102 # return self.application.testapp.debug
100 103
101 def get_current_user(self): 104 def get_current_user(self):
102 - ''' 105 + """
103 Since HTTP is stateless, a cookie is used to identify the user. 106 Since HTTP is stateless, a cookie is used to identify the user.
104 This function returns the cookie for the current user. 107 This function returns the cookie for the current user.
105 - '''  
106 - cookie = self.get_secure_cookie('perguntations_user') 108 + """
  109 + cookie = self.get_secure_cookie("perguntations_user")
107 if cookie: 110 if cookie:
108 - return cookie.decode('utf-8') 111 + return cookie.decode("utf-8")
109 return None 112 return None
110 113
111 114
112 # ---------------------------------------------------------------------------- 115 # ----------------------------------------------------------------------------
113 # pylint: disable=abstract-method 116 # pylint: disable=abstract-method
114 class LoginHandler(BaseHandler): 117 class LoginHandler(BaseHandler):
115 - '''Handles /login''' 118 + """Handles /login"""
116 119
117 - _prefix = re.compile(r'[a-z]') 120 + _prefix = re.compile(r"[a-z]")
118 _error_msg = { 121 _error_msg = {
119 - 'wrong_password': 'Senha errada',  
120 - 'not_allowed': 'Não está autorizado a fazer o teste',  
121 - 'nonexistent': 'Número de aluno inválido' 122 + "wrong_password": "Senha errada",
  123 + "not_allowed": "Não está autorizado a fazer o teste",
  124 + "nonexistent": "Número de aluno inválido",
122 } 125 }
123 126
124 def get(self): 127 def get(self):
125 - '''Render login page.'''  
126 - self.render('login.html', error='') 128 + """Render login page."""
  129 + self.render("login.html", error="")
127 130
128 async def post(self): 131 async def post(self):
129 - '''Authenticates student and login.'''  
130 - uid = self.get_body_argument('uid')  
131 - password = self.get_body_argument('pw') 132 + """Authenticates student and login."""
  133 + uid = self.get_body_argument("uid")
  134 + password = self.get_body_argument("pw")
132 headers = { 135 headers = {
133 - 'remote_ip': self.request.remote_ip,  
134 - 'user_agent': self.request.headers.get('User-Agent') 136 + "remote_ip": self.request.remote_ip,
  137 + "user_agent": self.request.headers.get("User-Agent"),
135 } 138 }
136 139
137 error = await self.testapp.login(uid, password, headers) 140 error = await self.testapp.login(uid, password, headers)
138 141
139 if error is not None: 142 if error is not None:
140 await asyncio.sleep(3) # delay to avoid spamming the server... 143 await asyncio.sleep(3) # delay to avoid spamming the server...
141 - self.render('login.html', error=self._error_msg[error]) 144 + self.render("login.html", error=self._error_msg[error])
142 else: 145 else:
143 - self.set_secure_cookie('perguntations_user', str(uid))  
144 - self.redirect('/') 146 + self.set_secure_cookie("perguntations_user", str(uid))
  147 + self.redirect("/")
145 148
146 149
147 # ---------------------------------------------------------------------------- 150 # ----------------------------------------------------------------------------
148 # pylint: disable=abstract-method 151 # pylint: disable=abstract-method
149 class LogoutHandler(BaseHandler): 152 class LogoutHandler(BaseHandler):
150 - '''Handle /logout''' 153 + """Handle /logout"""
151 154
152 @tornado.web.authenticated 155 @tornado.web.authenticated
153 def get(self): 156 def get(self):
154 - '''Logs out a user.''' 157 + """Logs out a user."""
155 self.testapp.logout(self.current_user) 158 self.testapp.logout(self.current_user)
156 - self.clear_cookie('perguntations_user')  
157 - self.render('login.html', error='') 159 + self.clear_cookie("perguntations_user")
  160 + self.render("login.html", error="")
158 161
159 162
160 # ---------------------------------------------------------------------------- 163 # ----------------------------------------------------------------------------
@@ -162,57 +165,64 @@ class LogoutHandler(BaseHandler): @@ -162,57 +165,64 @@ class LogoutHandler(BaseHandler):
162 # ---------------------------------------------------------------------------- 165 # ----------------------------------------------------------------------------
163 # pylint: disable=abstract-method 166 # pylint: disable=abstract-method
164 class RootHandler(BaseHandler): 167 class RootHandler(BaseHandler):
165 - ''' 168 + """
166 Presents test to student. 169 Presents test to student.
167 Receives answers, corrects the test and sends back the grade. 170 Receives answers, corrects the test and sends back the grade.
168 Redirects user 0 to /admin. 171 Redirects user 0 to /admin.
169 - ''' 172 + """
170 173
171 _templates = { 174 _templates = {
172 # -- question templates -- 175 # -- question templates --
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', 176 + "radio": "question-radio.html",
  177 + "checkbox": "question-checkbox.html",
  178 + "text": "question-text.html",
  179 + "text-regex": "question-text.html",
  180 + "numeric-interval": "question-text.html",
  181 + "textarea": "question-textarea.html",
179 # -- information panels -- 182 # -- information panels --
180 - 'information': 'question-information.html',  
181 - 'success': 'question-information.html',  
182 - 'warning': 'question-information.html',  
183 - 'alert': 'question-information.html', 183 + "information": "question-information.html",
  184 + "success": "question-information.html",
  185 + "warning": "question-information.html",
  186 + "alert": "question-information.html",
184 } 187 }
185 188
186 # --- GET 189 # --- GET
187 @tornado.web.authenticated 190 @tornado.web.authenticated
188 async def get(self): 191 async def get(self):
189 - ''' 192 + """
190 Handles GET / 193 Handles GET /
191 Sends test to student or redirects 0 to admin page. 194 Sends test to student or redirects 0 to admin page.
192 Multiple calls to this function will return the same test. 195 Multiple calls to this function will return the same test.
193 - ''' 196 + """
194 uid = self.current_user 197 uid = self.current_user
195 logger.debug('"%s" GET /', uid) 198 logger.debug('"%s" GET /', uid)
196 199
197 - if uid == '0':  
198 - self.redirect('/admin') 200 + if uid == "0":
  201 + self.redirect("/admin")
199 else: 202 else:
200 test = self.testapp.get_test(uid) 203 test = self.testapp.get_test(uid)
201 name = self.testapp.get_name(uid) 204 name = self.testapp.get_name(uid)
202 - self.render('test.html', t=test, uid=uid, name=name, md=md_to_html,  
203 - templ=self._templates, debug=self.testapp.debug) 205 + self.render(
  206 + "test.html",
  207 + t=test,
  208 + uid=uid,
  209 + name=name,
  210 + md=md_to_html,
  211 + templ=self._templates,
  212 + debug=self.testapp.debug,
  213 + )
204 214
205 # --- POST 215 # --- POST
206 @tornado.web.authenticated 216 @tornado.web.authenticated
207 async def post(self): 217 async def post(self):
208 - ''' 218 + """
209 Receives answers, fixes some html weirdness, corrects test and 219 Receives answers, fixes some html weirdness, corrects test and
210 renders the grade. 220 renders the grade.
211 221
212 self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} 222 self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']}
213 builds dictionary ans = {0: 'answer0', 1:, 'answer1', ...} 223 builds dictionary ans = {0: 'answer0', 1:, 'answer1', ...}
214 unanswered questions are not included. 224 unanswered questions are not included.
215 - ''' 225 + """
216 starttime = timer() # performance timer 226 starttime = timer() # performance timer
217 227
218 uid = self.current_user 228 uid = self.current_user
@@ -224,42 +234,47 @@ class RootHandler(BaseHandler): @@ -224,42 +234,47 @@ class RootHandler(BaseHandler):
224 raise tornado.web.HTTPError(403) # Forbidden 234 raise tornado.web.HTTPError(403) # Forbidden
225 235
226 ans = {} 236 ans = {}
227 - for i, question in enumerate(test['questions']): 237 + for i, question in enumerate(test["questions"]):
228 qid = str(i) 238 qid = str(i)
229 - if f'answered-{qid}' in self.request.arguments: 239 + if f"answered-{qid}" in self.request.arguments:
230 ans[i] = self.get_body_arguments(qid) 240 ans[i] = self.get_body_arguments(qid)
231 241
232 # remove enclosing list in some question types 242 # remove enclosing list in some question types
233 - if question['type'] == 'radio': 243 + if question["type"] == "radio":
234 ans[i] = ans[i][0] if ans[i] else None 244 ans[i] = ans[i][0] if ans[i] else None
235 - elif question['type'] in ('text', 'text-regex', 'textarea',  
236 - 'numeric-interval'): 245 + elif question["type"] in (
  246 + "text",
  247 + "text-regex",
  248 + "textarea",
  249 + "numeric-interval",
  250 + ):
237 ans[i] = ans[i][0] 251 ans[i] = ans[i][0]
238 252
239 # submit answered questions, correct 253 # submit answered questions, correct
240 await self.testapp.submit_test(uid, ans) 254 await self.testapp.submit_test(uid, ans)
241 255
242 name = self.testapp.get_name(uid) 256 name = self.testapp.get_name(uid)
243 - self.render('grade.html', t=test, uid=uid, name=name)  
244 - self.clear_cookie('perguntations_user') 257 + self.render("grade.html", t=test, uid=uid, name=name)
  258 + self.clear_cookie("perguntations_user")
245 self.testapp.logout(uid) 259 self.testapp.logout(uid)
246 - logger.info(' elapsed time: %fs', timer() - starttime) 260 + logger.info(" elapsed time: %fs", timer() - starttime)
247 261
248 262
249 # ---------------------------------------------------------------------------- 263 # ----------------------------------------------------------------------------
250 # pylint: disable=abstract-method 264 # pylint: disable=abstract-method
  265 +# FIXME also to update answers
251 class StudentWebservice(BaseHandler): 266 class StudentWebservice(BaseHandler):
252 - ''' 267 + """
253 Receive ajax from students during the test in response to the events 268 Receive ajax from students during the test in response to the events
254 - focus, unfocus and resize.  
255 - ''' 269 + focus, unfocus and resize, etc.
  270 + """
256 271
257 @tornado.web.authenticated 272 @tornado.web.authenticated
258 def post(self): 273 def post(self):
259 - '''handle ajax post''' 274 + """handle ajax post"""
260 uid = self.current_user 275 uid = self.current_user
261 - cmd = self.get_body_argument('cmd', None)  
262 - value = self.get_body_argument('value', None) 276 + cmd = self.get_body_argument("cmd", None)
  277 + value = self.get_body_argument("value", None)
263 if cmd is not None and value is not None: 278 if cmd is not None and value is not None:
264 self.testapp.register_event(uid, cmd, json.loads(value)) 279 self.testapp.register_event(uid, cmd, json.loads(value))
265 280
@@ -267,29 +282,31 @@ class StudentWebservice(BaseHandler): @@ -267,29 +282,31 @@ class StudentWebservice(BaseHandler):
267 # ---------------------------------------------------------------------------- 282 # ----------------------------------------------------------------------------
268 # pylint: disable=abstract-method 283 # pylint: disable=abstract-method
269 class AdminWebservice(BaseHandler): 284 class AdminWebservice(BaseHandler):
270 - ''' 285 + """
271 Receive ajax requests from admin 286 Receive ajax requests from admin
272 - ''' 287 + """
273 288
274 @tornado.web.authenticated 289 @tornado.web.authenticated
275 @admin_only 290 @admin_only
276 async def get(self): 291 async def get(self):
277 - '''admin webservices that do not change state'''  
278 - cmd = self.get_query_argument('cmd')  
279 - logger.debug('GET /adminwebservice %s', cmd) 292 + """admin webservices that do not change state"""
  293 + cmd = self.get_query_argument("cmd")
  294 + logger.debug("GET /adminwebservice %s", cmd)
280 295
281 - if cmd == 'testcsv': 296 + if cmd == "testcsv":
282 test_ref, data = self.testapp.get_grades_csv() 297 test_ref, data = self.testapp.get_grades_csv()
283 - self.set_header('Content-Type', 'text/csv')  
284 - self.set_header('content-Disposition',  
285 - f'attachment; filename={test_ref}.csv') 298 + self.set_header("Content-Type", "text/csv")
  299 + self.set_header(
  300 + "content-Disposition", f"attachment; filename={test_ref}.csv"
  301 + )
286 self.write(data) 302 self.write(data)
287 await self.flush() 303 await self.flush()
288 - elif cmd == 'questionscsv': 304 + elif cmd == "questionscsv":
289 test_ref, data = self.testapp.get_detailed_grades_csv() 305 test_ref, data = self.testapp.get_detailed_grades_csv()
290 - self.set_header('Content-Type', 'text/csv')  
291 - self.set_header('content-Disposition',  
292 - f'attachment; filename={test_ref}-detailed.csv') 306 + self.set_header("Content-Type", "text/csv")
  307 + self.set_header(
  308 + "content-Disposition", f"attachment; filename={test_ref}-detailed.csv"
  309 + )
293 self.write(data) 310 self.write(data)
294 await self.flush() 311 await self.flush()
295 312
@@ -297,52 +314,53 @@ class AdminWebservice(BaseHandler): @@ -297,52 +314,53 @@ class AdminWebservice(BaseHandler):
297 # ---------------------------------------------------------------------------- 314 # ----------------------------------------------------------------------------
298 # pylint: disable=abstract-method 315 # pylint: disable=abstract-method
299 class AdminHandler(BaseHandler): 316 class AdminHandler(BaseHandler):
300 - '''Handle /admin''' 317 + """Handle /admin"""
301 318
302 # --- GET 319 # --- GET
303 @tornado.web.authenticated 320 @tornado.web.authenticated
304 @admin_only 321 @admin_only
305 async def get(self): 322 async def get(self):
306 - ''' 323 + """
307 Admin page. 324 Admin page.
308 - '''  
309 - cmd = self.get_query_argument('cmd', default=None)  
310 - logger.debug('GET /admin (cmd=%s)', cmd) 325 + """
  326 + cmd = self.get_query_argument("cmd", default=None)
  327 + logger.debug("GET /admin (cmd=%s)", cmd)
311 328
312 if cmd is None: 329 if cmd is None:
313 - self.render('admin.html')  
314 - elif cmd == 'test':  
315 - data = { 'data': self.testapp.get_test_config() } 330 + self.render("admin.html")
  331 + elif cmd == "test":
  332 + data = {"data": self.testapp.get_test_config()}
316 self.write(json.dumps(data, default=str)) 333 self.write(json.dumps(data, default=str))
317 - elif cmd == 'students_table':  
318 - data = {'data': self.testapp.get_students_state()} 334 + elif cmd == "students_table":
  335 + data = {"data": self.testapp.get_students_state()}
319 self.write(json.dumps(data, default=str)) 336 self.write(json.dumps(data, default=str))
320 337
321 # --- POST 338 # --- POST
322 @tornado.web.authenticated 339 @tornado.web.authenticated
323 @admin_only 340 @admin_only
324 async def post(self): 341 async def post(self):
325 - ''' 342 + """
326 Executes commands from the admin page. 343 Executes commands from the admin page.
327 - '''  
328 - cmd = self.get_body_argument('cmd', None)  
329 - value = self.get_body_argument('value', None)  
330 - logger.debug('POST /admin (cmd=%s, value=%s)', cmd, value) 344 + """
  345 + cmd = self.get_body_argument("cmd", None)
  346 + value = self.get_body_argument("value", None)
  347 + logger.debug("POST /admin (cmd=%s, value=%s)", cmd, value)
331 348
332 - if cmd == 'allow': 349 + if cmd == "allow":
333 self.testapp.allow_student(value) 350 self.testapp.allow_student(value)
334 - elif cmd == 'deny': 351 + elif cmd == "deny":
335 self.testapp.deny_student(value) 352 self.testapp.deny_student(value)
336 - elif cmd == 'allow_all': 353 + elif cmd == "allow_all":
337 self.testapp.allow_all_students() 354 self.testapp.allow_all_students()
338 - elif cmd == 'deny_all': 355 + elif cmd == "deny_all":
339 self.testapp.deny_all_students() 356 self.testapp.deny_all_students()
340 - elif cmd == 'reset_password':  
341 - await self.testapp.set_password(uid=value, password='')  
342 - elif cmd == 'insert_student' and value is not None: 357 + elif cmd == "reset_password":
  358 + await self.testapp.set_password(uid=value, password="")
  359 + elif cmd == "insert_student" and value is not None:
343 student = json.loads(value) 360 student = json.loads(value)
344 - await self.testapp.insert_new_student(uid=student['number'],  
345 - name=student['name']) 361 + await self.testapp.insert_new_student(
  362 + uid=student["number"], name=student["name"]
  363 + )
346 364
347 365
348 # ---------------------------------------------------------------------------- 366 # ----------------------------------------------------------------------------
@@ -350,22 +368,22 @@ class AdminHandler(BaseHandler): @@ -350,22 +368,22 @@ class AdminHandler(BaseHandler):
350 # ---------------------------------------------------------------------------- 368 # ----------------------------------------------------------------------------
351 # pylint: disable=abstract-method 369 # pylint: disable=abstract-method
352 class FileHandler(BaseHandler): 370 class FileHandler(BaseHandler):
353 - ''' 371 + """
354 Handles static files from questions like images, etc. 372 Handles static files from questions like images, etc.
355 - ''' 373 + """
356 374
357 _filecache: Dict[Tuple[str, str], bytes] = {} 375 _filecache: Dict[Tuple[str, str], bytes] = {}
358 376
359 @tornado.web.authenticated 377 @tornado.web.authenticated
360 async def get(self): 378 async def get(self):
361 - ''' 379 + """
362 Returns requested file. Files are obtained from the 'public' directory 380 Returns requested file. Files are obtained from the 'public' directory
363 of each question. 381 of each question.
364 - ''' 382 + """
365 uid = self.current_user 383 uid = self.current_user
366 - ref = self.get_query_argument('ref', None)  
367 - image = self.get_query_argument('image', None)  
368 - logger.debug('GET /file (ref=%s, image=%s)', ref, image) 384 + ref = self.get_query_argument("ref", None)
  385 + image = self.get_query_argument("image", None)
  386 + logger.debug("GET /file (ref=%s, image=%s)", ref, image)
369 387
370 if ref is None or image is None: 388 if ref is None or image is None:
371 return 389 return
@@ -373,7 +391,7 @@ class FileHandler(BaseHandler): @@ -373,7 +391,7 @@ class FileHandler(BaseHandler):
373 content_type = mimetypes.guess_type(image)[0] 391 content_type = mimetypes.guess_type(image)[0]
374 392
375 if (ref, image) in self._filecache: 393 if (ref, image) in self._filecache:
376 - logger.debug('using cached file') 394 + logger.debug("using cached file")
377 self.write(self._filecache[(ref, image)]) 395 self.write(self._filecache[(ref, image)])
378 if content_type is not None: 396 if content_type is not None:
379 self.set_header("Content-Type", content_type) 397 self.set_header("Content-Type", content_type)
@@ -383,16 +401,16 @@ class FileHandler(BaseHandler): @@ -383,16 +401,16 @@ class FileHandler(BaseHandler):
383 try: 401 try:
384 test = self.testapp.get_test(uid) 402 test = self.testapp.get_test(uid)
385 except KeyError: 403 except KeyError:
386 - logger.warning('Could not get test to serve image file') 404 + logger.warning("Could not get test to serve image file")
387 raise tornado.web.HTTPError(404) from None # Not Found 405 raise tornado.web.HTTPError(404) from None # Not Found
388 406
389 # search for the question that contains the image 407 # search for the question that contains the image
390 - for question in test['questions']:  
391 - if question['ref'] == ref:  
392 - filepath = path.join(question['path'], 'public', image) 408 + for question in test["questions"]:
  409 + if question["ref"] == ref:
  410 + filepath = path.join(question["path"], "public", image)
393 411
394 try: 412 try:
395 - with open(filepath, 'rb') as file: 413 + with open(filepath, "rb") as file:
396 data = file.read() 414 data = file.read()
397 except OSError: 415 except OSError:
398 logger.error('Error reading file "%s"', filepath) 416 logger.error('Error reading file "%s"', filepath)
@@ -408,39 +426,39 @@ class FileHandler(BaseHandler): @@ -408,39 +426,39 @@ class FileHandler(BaseHandler):
408 # --- REVIEW ----------------------------------------------------------------- 426 # --- REVIEW -----------------------------------------------------------------
409 # pylint: disable=abstract-method 427 # pylint: disable=abstract-method
410 class ReviewHandler(BaseHandler): 428 class ReviewHandler(BaseHandler):
411 - ''' 429 + """
412 Show test for review 430 Show test for review
413 - ''' 431 + """
414 432
415 _templates = { 433 _templates = {
416 - 'radio': 'review-question-radio.html',  
417 - 'checkbox': 'review-question-checkbox.html',  
418 - 'text': 'review-question-text.html',  
419 - 'text-regex': 'review-question-text.html',  
420 - 'numeric-interval': 'review-question-text.html',  
421 - 'textarea': 'review-question-text.html', 434 + "radio": "review-question-radio.html",
  435 + "checkbox": "review-question-checkbox.html",
  436 + "text": "review-question-text.html",
  437 + "text-regex": "review-question-text.html",
  438 + "numeric-interval": "review-question-text.html",
  439 + "textarea": "review-question-text.html",
422 # -- information panels -- 440 # -- information panels --
423 - 'information': 'review-question-information.html',  
424 - 'success': 'review-question-information.html',  
425 - 'warning': 'review-question-information.html',  
426 - 'alert': 'review-question-information.html', 441 + "information": "review-question-information.html",
  442 + "success": "review-question-information.html",
  443 + "warning": "review-question-information.html",
  444 + "alert": "review-question-information.html",
427 } 445 }
428 446
429 @tornado.web.authenticated 447 @tornado.web.authenticated
430 @admin_only 448 @admin_only
431 async def get(self): 449 async def get(self):
432 - ''' 450 + """
433 Opens JSON file with a given corrected test and renders it 451 Opens JSON file with a given corrected test and renders it
434 - '''  
435 - test_id = self.get_query_argument('test_id', None)  
436 - logger.info('Review test %s.', test_id) 452 + """
  453 + test_id = self.get_query_argument("test_id", None)
  454 + logger.info("Review test %s.", test_id)
437 fname = self.testapp.get_json_filename_of_test(test_id) 455 fname = self.testapp.get_json_filename_of_test(test_id)
438 456
439 if fname is None: 457 if fname is None:
440 raise tornado.web.HTTPError(404) # Not Found 458 raise tornado.web.HTTPError(404) # Not Found
441 459
442 try: 460 try:
443 - with open(path.expanduser(fname), encoding='utf-8') as jsonfile: 461 + with open(path.expanduser(fname), encoding="utf-8") as jsonfile:
444 test = json.load(jsonfile) 462 test = json.load(jsonfile)
445 except OSError: 463 except OSError:
446 msg = f'Cannot open "{fname}" for review.' 464 msg = f'Cannot open "{fname}" for review.'
@@ -451,57 +469,65 @@ class ReviewHandler(BaseHandler): @@ -451,57 +469,65 @@ class ReviewHandler(BaseHandler):
451 logger.error(msg) 469 logger.error(msg)
452 raise tornado.web.HTTPError(status_code=404, reason=msg) 470 raise tornado.web.HTTPError(status_code=404, reason=msg)
453 471
454 - uid = test['student'] 472 + uid = test["student"]
455 name = self.testapp.get_name(uid) 473 name = self.testapp.get_name(uid)
456 - self.render('review.html', t=test, uid=uid, name=name, md=md_to_html,  
457 - templ=self._templates, debug=self.testapp.debug) 474 + self.render(
  475 + "review.html",
  476 + t=test,
  477 + uid=uid,
  478 + name=name,
  479 + md=md_to_html,
  480 + templ=self._templates,
  481 + debug=self.testapp.debug,
  482 + )
458 483
459 484
460 # ---------------------------------------------------------------------------- 485 # ----------------------------------------------------------------------------
461 def signal_handler(*_): 486 def signal_handler(*_):
462 - ''' 487 + """
463 Catches Ctrl-C and stops webserver 488 Catches Ctrl-C and stops webserver
464 - '''  
465 - reply = input(' --> Stop webserver? (yes/no) ')  
466 - if reply.lower() == 'yes': 489 + """
  490 + reply = input(" --> Stop webserver? (yes/no) ")
  491 + if reply.lower() == "yes":
467 tornado.ioloop.IOLoop.current().stop() 492 tornado.ioloop.IOLoop.current().stop()
468 - logger.critical('Webserver stopped.') 493 + logger.critical("Webserver stopped.")
469 sys.exit(0) 494 sys.exit(0)
470 495
  496 +
471 # ---------------------------------------------------------------------------- 497 # ----------------------------------------------------------------------------
472 def run_webserver(app, ssl_opt, port, debug): 498 def run_webserver(app, ssl_opt, port, debug):
473 - ''' 499 + """
474 Starts and runs webserver until a SIGINT signal (Ctrl-C) is received. 500 Starts and runs webserver until a SIGINT signal (Ctrl-C) is received.
475 - ''' 501 + """
476 502
477 # --- create web application 503 # --- create web application
478 - logger.info('-------- Starting WebApplication (tornado) --------') 504 + logger.info("-------- Starting WebApplication (tornado) --------")
479 try: 505 try:
480 webapp = WebApplication(app, debug=debug) 506 webapp = WebApplication(app, debug=debug)
481 except Exception: 507 except Exception:
482 - logger.critical('Failed to start web application.') 508 + logger.critical("Failed to start web application.")
483 raise 509 raise
484 510
485 # --- create httpserver 511 # --- create httpserver
486 try: 512 try:
487 httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt) 513 httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt)
488 except ValueError: 514 except ValueError:
489 - logger.critical('Certificates cert.pem, privkey.pem not found') 515 + logger.critical("Certificates cert.pem, privkey.pem not found")
490 sys.exit(1) 516 sys.exit(1)
491 517
492 try: 518 try:
493 httpserver.listen(port) 519 httpserver.listen(port)
494 except OSError: 520 except OSError:
495 - logger.critical('Cannot bind port %d. Already in use?', port) 521 + logger.critical("Cannot bind port %d. Already in use?", port)
496 sys.exit(1) 522 sys.exit(1)
497 523
498 - logger.info('Listening on port %d... (Ctrl-C to stop)', port) 524 + logger.info("Listening on port %d... (Ctrl-C to stop)", port)
499 signal.signal(signal.SIGINT, signal_handler) 525 signal.signal(signal.SIGINT, signal_handler)
500 526
501 # --- run webserver 527 # --- run webserver
502 try: 528 try:
503 tornado.ioloop.IOLoop.current().start() # running... 529 tornado.ioloop.IOLoop.current().start() # running...
504 except Exception: 530 except Exception:
505 - logger.critical('Webserver stopped!') 531 + logger.critical("Webserver stopped!")
506 tornado.ioloop.IOLoop.current().stop() 532 tornado.ioloop.IOLoop.current().stop()
507 raise 533 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,64 +40,69 @@ def check_import_files(files: list) -&gt; bool: @@ -39,64 +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)  
65 - # FIXME schema error with 'testfile' which is added in the code 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
66 70
67 # ============================================================================ 71 # ============================================================================
68 class TestFactoryException(Exception): 72 class TestFactoryException(Exception):
69 - '''exception raised in this module''' 73 + """exception raised in this module"""
70 74
71 75
72 # ============================================================================ 76 # ============================================================================
73 class TestFactory(dict): 77 class TestFactory(dict):
74 - ''' 78 + """
75 Each instance of TestFactory() is a test generator. 79 Each instance of TestFactory() is a test generator.
76 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
77 instances of TestFactory(), one for each test. 81 instances of TestFactory(), one for each test.
78 - ''' 82 + """
79 83
80 # ------------------------------------------------------------------------ 84 # ------------------------------------------------------------------------
81 def __init__(self, conf) -> None: 85 def __init__(self, conf) -> None:
82 - ''' 86 + """
83 Loads configuration from yaml file, then overrides some configurations 87 Loads configuration from yaml file, then overrides some configurations
84 using the conf argument. 88 using the conf argument.
85 Base questions are added to a pool of questions factories. 89 Base questions are added to a pool of questions factories.
86 - ''' 90 + """
87 91
88 test_schema.validate(conf) 92 test_schema.validate(conf)
89 93
90 # --- set test defaults and then use given configuration 94 # --- set test defaults and then use given configuration
91 - super().__init__({ # defaults  
92 - 'show_points': True,  
93 - 'scale': None,  
94 - 'duration': 0, # 0=infinite  
95 - 'autosubmit': False,  
96 - 'autocorrect': True,  
97 - }) 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 + )
98 self.update(conf) 104 self.update(conf)
99 - normalize_question_list(self['questions']) 105 + normalize_question_list(self["questions"])
100 106
101 # --- for review, we are done. no factories needed 107 # --- for review, we are done. no factories needed
102 # if self['review']: FIXME 108 # if self['review']: FIXME
@@ -104,53 +110,57 @@ class TestFactory(dict): @@ -104,53 +110,57 @@ class TestFactory(dict):
104 # return 110 # return
105 111
106 # --- find refs of all questions used in the test 112 # --- find refs of all questions used in the test
107 - qrefs = {r for qq in self['questions'] for r in qq['ref']}  
108 - logger.info('Declared %d questions (each test uses %d).',  
109 - 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 + )
110 119
111 # --- load and build question factories 120 # --- load and build question factories
112 - self['question_factory'] = {} 121 + self["question_factory"] = {}
113 122
114 for file in self["files"]: 123 for file in self["files"]:
115 fullpath = path.normpath(file) 124 fullpath = path.normpath(file)
116 125
117 logger.info('Loading "%s"...', fullpath) 126 logger.info('Loading "%s"...', fullpath)
118 - questions = load_yaml(fullpath) # , default=[]) 127 + questions = load_yaml(fullpath) # , default=[])
119 128
120 for i, question in enumerate(questions): 129 for i, question in enumerate(questions):
121 # make sure every question in the file is a dictionary 130 # make sure every question in the file is a dictionary
122 if not isinstance(question, dict): 131 if not isinstance(question, dict):
123 - msg = f'Question {i} in {file} is not a dictionary' 132 + msg = f"Question {i} in {file} is not a dictionary"
124 raise TestFactoryException(msg) 133 raise TestFactoryException(msg)
125 134
126 # check if ref is missing, then set to '//file.yaml:3' 135 # check if ref is missing, then set to '//file.yaml:3'
127 - if 'ref' not in question:  
128 - question['ref'] = f'{file}:{i:04}' 136 + if "ref" not in question:
  137 + question["ref"] = f"{file}:{i:04}"
129 logger.warning('Missing ref set to "%s"', question["ref"]) 138 logger.warning('Missing ref set to "%s"', question["ref"])
130 139
131 # check for duplicate refs 140 # check for duplicate refs
132 - qref = question['ref']  
133 - if qref in self['question_factory']:  
134 - other = self['question_factory'][qref]  
135 - otherfile = path.join(other.question['path'],  
136 - 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 + )
137 msg = f'Duplicate "{qref}" in {otherfile} and {fullpath}' 147 msg = f'Duplicate "{qref}" in {otherfile} and {fullpath}'
138 raise TestFactoryException(msg) 148 raise TestFactoryException(msg)
139 149
140 # make factory only for the questions used in the test 150 # make factory only for the questions used in the test
141 if qref in qrefs: 151 if qref in qrefs:
142 - question.update(zip(('path', 'filename', 'index'),  
143 - path.split(fullpath) + (i,)))  
144 - 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))
145 156
146 - qmissing = qrefs.difference(set(self['question_factory'].keys())) 157 + qmissing = qrefs.difference(set(self["question_factory"].keys()))
147 if qmissing: 158 if qmissing:
148 - raise TestFactoryException(f'Could not find questions {qmissing}.') 159 + raise TestFactoryException(f"Could not find questions {qmissing}.")
149 160
150 self.check_questions() 161 self.check_questions()
151 162
152 - logger.info('Test factory ready. No errors found.')  
153 - 163 + logger.info("Test factory ready. No errors found.")
154 164
155 # ------------------------------------------------------------------------ 165 # ------------------------------------------------------------------------
156 # def check_test_ref(self) -> None: 166 # def check_test_ref(self) -> None:
@@ -202,8 +212,8 @@ class TestFactory(dict): @@ -202,8 +212,8 @@ class TestFactory(dict):
202 # 'question files to import!') 212 # 'question files to import!')
203 # raise TestFactoryException(msg) 213 # raise TestFactoryException(msg)
204 214
205 - # if isinstance(self['files'], str):  
206 - # self['files'] = [self['files']] 215 + # if isinstance(self['files'], str):
  216 + # self['files'] = [self['files']]
207 217
208 # def check_question_list(self) -> None: 218 # def check_question_list(self) -> None:
209 # '''normalize question list''' 219 # '''normalize question list'''
@@ -235,127 +245,140 @@ class TestFactory(dict): @@ -235,127 +245,140 @@ class TestFactory(dict):
235 # logger.warning(msg) 245 # logger.warning(msg)
236 # self['scale'] = [self['scale_min'], self['scale_max']] 246 # self['scale'] = [self['scale_min'], self['scale_max']]
237 247
238 -  
239 # ------------------------------------------------------------------------ 248 # ------------------------------------------------------------------------
240 # def sanity_checks(self) -> None: 249 # def sanity_checks(self) -> None:
241 # ''' 250 # '''
242 # Checks for valid keys and sets default values. 251 # Checks for valid keys and sets default values.
243 # Also checks if some files and directories exist 252 # Also checks if some files and directories exist
244 # ''' 253 # '''
245 - # self.check_test_ref()  
246 - # self.check_missing_database()  
247 - # self.check_missing_answers_directory()  
248 - # self.check_answers_directory_writable()  
249 - # self.check_questions_directory()  
250 - # self.check_import_files()  
251 - # self.check_question_list()  
252 - # self.check_missing_title()  
253 - # 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()
254 263
255 # ------------------------------------------------------------------------ 264 # ------------------------------------------------------------------------
256 def check_questions(self) -> None: 265 def check_questions(self) -> None:
257 - ''' 266 + """
258 checks if questions can be correctly generated and corrected 267 checks if questions can be correctly generated and corrected
259 - '''  
260 - logger.info('Checking questions...') 268 + """
  269 + logger.info("Checking questions...")
261 # FIXME get_event_loop will be deprecated in python3.10 270 # FIXME get_event_loop will be deprecated in python3.10
262 loop = asyncio.get_event_loop() 271 loop = asyncio.get_event_loop()
263 - for i, (qref, qfact) in enumerate(self['question_factory'].items()): 272 + for i, (qref, qfact) in enumerate(self["question_factory"].items()):
264 try: 273 try:
265 question = loop.run_until_complete(qfact.gen_async()) 274 question = loop.run_until_complete(qfact.gen_async())
266 except Exception as exc: 275 except Exception as exc:
267 msg = f'Failed to generate "{qref}"' 276 msg = f'Failed to generate "{qref}"'
268 raise TestFactoryException(msg) from exc 277 raise TestFactoryException(msg) from exc
269 else: 278 else:
270 - logger.info('%4d. %s: Ok', i, qref) 279 + logger.info("%4d. %s: Ok", i, qref)
271 280
272 - if question['type'] == 'textarea': 281 + if question["type"] == "textarea":
273 _runtests_textarea(qref, question) 282 _runtests_textarea(qref, question)
274 283
275 # ------------------------------------------------------------------------ 284 # ------------------------------------------------------------------------
276 async def generate(self): 285 async def generate(self):
277 - ''' 286 + """
278 Given a dictionary with a student dict {'name':'john', 'number': 123} 287 Given a dictionary with a student dict {'name':'john', 'number': 123}
279 returns instance of Test() for that particular student 288 returns instance of Test() for that particular student
280 - ''' 289 + """
281 290
282 # make list of questions 291 # make list of questions
283 questions = [] 292 questions = []
284 qnum = 1 # track question number 293 qnum = 1 # track question number
285 nerr = 0 # count errors during questions generation 294 nerr = 0 # count errors during questions generation
286 295
287 - for qlist in self['questions']: 296 + for qlist in self["questions"]:
288 # choose list of question variants 297 # choose list of question variants
289 - choose = qlist.get('choose', 1)  
290 - qrefs = random.sample(qlist['ref'], k=choose) 298 + choose = qlist.get("choose", 1)
  299 + qrefs = random.sample(qlist["ref"], k=choose)
291 300
292 for qref in qrefs: 301 for qref in qrefs:
293 # generate instance of question 302 # generate instance of question
294 try: 303 try:
295 - question = await self['question_factory'][qref].gen_async() 304 + question = await self["question_factory"][qref].gen_async()
296 except QuestionException: 305 except QuestionException:
297 logger.error('Can\'t generate question "%s". Skipping.', qref) 306 logger.error('Can\'t generate question "%s". Skipping.', qref)
298 nerr += 1 307 nerr += 1
299 continue 308 continue
300 309
301 # some defaults 310 # some defaults
302 - if question['type'] in ('information', 'success', 'warning',  
303 - 'alert'):  
304 - question['points'] = qlist.get('points', 0.0) 311 + if question["type"] in ("information", "success", "warning", "alert"):
  312 + question["points"] = qlist.get("points", 0.0)
305 else: 313 else:
306 - question['points'] = qlist.get('points', 1.0)  
307 - 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
308 qnum += 1 316 qnum += 1
309 317
310 questions.append(question) 318 questions.append(question)
311 319
312 # setup scale 320 # setup scale
313 - total_points = sum(q['points'] for q in questions) 321 + total_points = sum(q["points"] for q in questions)
314 322
315 if total_points > 0: 323 if total_points > 0:
316 # normalize question points to scale 324 # normalize question points to scale
317 - if self['scale'] is not None:  
318 - scale_min, scale_max = self['scale'] 325 + if self["scale"] is not None:
  326 + scale_min, scale_max = self["scale"]
319 factor = (scale_max - scale_min) / total_points 327 factor = (scale_max - scale_min) / total_points
320 for question in questions: 328 for question in questions:
321 - question['points'] *= factor  
322 - logger.debug('Points normalized from %g to [%g, %g]',  
323 - total_points, scale_min, scale_max) 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 + )
324 else: 336 else:
325 - self['scale'] = [0, total_points] 337 + self["scale"] = [0, total_points]
326 else: 338 else:
327 - logger.warning('Total points is **ZERO**.')  
328 - if self['scale'] is None:  
329 - self['scale'] = [0, 20] # default 339 + logger.warning("Total points is **ZERO**.")
  340 + if self["scale"] is None:
  341 + self["scale"] = [0, 20] # default
330 342
331 if nerr > 0: 343 if nerr > 0:
332 - logger.error('%s errors found!', nerr) 344 + logger.error("%s errors found!", nerr)
333 345
334 # copy these from the test configuratoin to each test instance 346 # copy these from the test configuratoin to each test instance
335 - inherit = ['ref', 'title', 'database', 'answers_dir', 'files', 'scale',  
336 - 'duration', 'autosubmit', 'autocorrect', 'show_points']  
337 -  
338 - 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}})
339 361
340 # ------------------------------------------------------------------------ 362 # ------------------------------------------------------------------------
341 def __repr__(self): 363 def __repr__(self):
342 - testsettings = '\n'.join(f' {k:14s}: {v}' for k, v in self.items())  
343 - 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 +
344 367
345 # ============================================================================ 368 # ============================================================================
346 def _runtests_textarea(qref, question): 369 def _runtests_textarea(qref, question):
347 - ''' 370 + """
348 Checks if correction script works and runs tests if available 371 Checks if correction script works and runs tests if available
349 - ''' 372 + """
350 try: 373 try:
351 - question.set_answer('') 374 + question.set_answer("")
352 question.correct() 375 question.correct()
353 except Exception as exc: 376 except Exception as exc:
354 msg = f'Failed to correct "{qref}"' 377 msg = f'Failed to correct "{qref}"'
355 raise TestFactoryException(msg) from exc 378 raise TestFactoryException(msg) from exc
356 - logger.info(' correction works') 379 + logger.info(" correction works")
357 380
358 - for tnum, right_answer in enumerate(question.get('tests_right', {})): 381 + for tnum, right_answer in enumerate(question.get("tests_right", {})):
359 try: 382 try:
360 question.set_answer(right_answer) 383 question.set_answer(right_answer)
361 question.correct() 384 question.correct()
@@ -363,12 +386,12 @@ def _runtests_textarea(qref, question): @@ -363,12 +386,12 @@ def _runtests_textarea(qref, question):
363 msg = f'Failed to correct "{qref}"' 386 msg = f'Failed to correct "{qref}"'
364 raise TestFactoryException(msg) from exc 387 raise TestFactoryException(msg) from exc
365 388
366 - if question['grade'] == 1.0:  
367 - logger.info(' tests_right[%i] Ok', tnum) 389 + if question["grade"] == 1.0:
  390 + logger.info(" tests_right[%i] Ok", tnum)
368 else: 391 else:
369 - logger.error(' tests_right[%i] FAILED!!!', tnum) 392 + logger.error(" tests_right[%i] FAILED!!!", tnum)
370 393
371 - for tnum, wrong_answer in enumerate(question.get('tests_wrong', {})): 394 + for tnum, wrong_answer in enumerate(question.get("tests_wrong", {})):
372 try: 395 try:
373 question.set_answer(wrong_answer) 396 question.set_answer(wrong_answer)
374 question.correct() 397 question.correct()
@@ -376,7 +399,7 @@ def _runtests_textarea(qref, question): @@ -376,7 +399,7 @@ def _runtests_textarea(qref, question):
376 msg = f'Failed to correct "{qref}"' 399 msg = f'Failed to correct "{qref}"'
377 raise TestFactoryException(msg) from exc 400 raise TestFactoryException(msg) from exc
378 401
379 - if question['grade'] < 1.0:  
380 - logger.info(' tests_wrong[%i] Ok', tnum) 402 + if question["grade"] < 1.0:
  403 + logger.info(" tests_wrong[%i] Ok", tnum)
381 else: 404 else:
382 - 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