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