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