Commit cc91e4c034aa4336bad33042438dd98d4f20d958

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