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