Commit 7e33bb8de4d94de9e28621d383dc5a66b5001e66

Authored by Miguel Barão
1 parent 1db56dc6
Exists in master and in 1 other branch dev

- new generator question in demo.yaml

- fix grade format in logs
- fix many pyyaml warnings
- rewrite run_script and run_script_async
demo/demo.yaml
@@ -65,7 +65,7 @@ questions: @@ -65,7 +65,7 @@ questions:
65 - tut-success 65 - tut-success
66 - tut-warning 66 - tut-warning
67 - tut-alert 67 - tut-alert
68 - 68 + - tut-generator
69 69
70 # test: 70 # test:
71 # - ref1 71 # - ref1
demo/questions/generators/generate-question.py
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
  3 +'''
  4 +Example of a question generator.
  5 +Arguments are read from stdin.
  6 +'''
  7 +
3 from random import randint 8 from random import randint
4 import sys 9 import sys
5 10
6 -arg = sys.stdin.read() # read arguments 11 +a, b = (int(n) for n in sys.argv[1:])
7 12
8 -a, b = (int(n) for n in arg.split(',')) 13 +x = randint(a, b)
  14 +y = randint(a, b)
  15 +r = x + y
9 16
10 -q = f'''---  
11 -type: checkbox 17 +print(f"""---
  18 +type: text
  19 +title: Geradores de perguntas
12 text: | 20 text: |
13 - Indique quais das seguintes adições resultam em overflow quando se considera  
14 - a adição de números com sinal (complemento para 2) em registos de 8 bits. 21 + Existe a possibilidade da pergunta ser gerada por um programa externo.
  22 + Este programa deve escrever no `stdout` uma pergunta em formato `yaml` como
  23 + os anteriores. Pode também receber argumentos para parametrizar a geração da
  24 + pergunta. Aqui está um exemplo de uma pergunta gerada por um script python:
15 25
16 - Os números foram gerados aleatoriamente no intervalo de {a} a {b}.  
17 -options:  
18 -''' 26 + ```python
  27 + #!/usr/bin/env python3
  28 +
  29 + from random import randint
  30 + import sys
  31 +
  32 + a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando
19 33
20 -correct = []  
21 -for i in range(5):  
22 - x = randint(a, b)  
23 - y = randint(a, b)  
24 - q += f' - "`{x} + {y}`"\n'  
25 - correct.append(1 if x + y > 127 else -1) 34 + x = randint(a, b)
  35 + y = randint(a, b)
  36 + r = x + y
26 37
27 -q += 'correct: ' + str(correct) 38 + print(f'''---
  39 + type: text
  40 + title: Contas de somar
  41 + text: |
  42 + bla bla bla
  43 + correct: '{{r}}'
  44 + solution: |
  45 + A solução é {{r}}.''')
  46 + ```
28 47
29 -print(q) 48 + Este script deve ter permissões para poder ser executado no terminal. Dá
  49 + jeito usar o comando `gen-somar.py 1 100 | yamllint -` para validar o `yaml`
  50 + gerado.
  51 +
  52 + Para indicar que uma pergunta é gerada externamente, esta é declarada com
  53 +
  54 + ```yaml
  55 + - type: generator
  56 + ref: gen-somar
  57 + script: gen-somar.py
  58 + # opcional
  59 + args: [1, 100]
  60 + ```
  61 +
  62 + Os argumentos `args` são opcionais e são passados para o programa como
  63 + argumentos da linha de comando.
  64 +
  65 + ---
  66 +
  67 + Calcule o resultado de ${x} + {y}$.
  68 +
  69 + Os números foram gerados aleatoriamente no intervalo de {a} a {b}.
  70 +correct: '{r}'
  71 +solution: |
  72 + A solução é {r}.""")
demo/questions/questions-tutorial.yaml
@@ -22,6 +22,7 @@ @@ -22,6 +22,7 @@
22 22
23 # opcional 23 # opcional
24 duration: 60 # duração da prova em minutos (default: inf) 24 duration: 60 # duração da prova em minutos (default: inf)
  25 + autosubmit: true # submissão automática (default: false)
25 show_points: true # mostra cotação das perguntas (default: true) 26 show_points: true # mostra cotação das perguntas (default: true)
26 scale_points: true # recalcula cotações para [scale_min, scale_max] 27 scale_points: true # recalcula cotações para [scale_min, scale_max]
27 scale_max: 20 # limite superior da escala (default: 20) 28 scale_max: 20 # limite superior da escala (default: 20)
@@ -159,11 +160,10 @@ @@ -159,11 +160,10 @@
159 shuffle: false 160 shuffle: false
160 ``` 161 ```
161 162
162 - Por defeito, as respostas erradas descontam, tendo uma cotação de -1/(n-1)  
163 - do valor da pergunta, onde n é o número de opções apresentadas ao aluno  
164 - (a ideia é o valor esperado ser zero quando as respostas são aleatórias e  
165 - uniformemente distribuídas).  
166 - Para não descontar acrescenta-se: 163 + Por defeito, as respostas erradas descontam, tendo uma cotação de
  164 + $-1/(n-1)$ do valor da pergunta, onde $n$ é o número de opções apresentadas
  165 + ao aluno (a ideia é o valor esperado ser zero quando as respostas são
  166 + aleatórias e uniformemente distribuídas). Para não descontar acrescenta-se:
167 167
168 ```yaml 168 ```yaml
169 discount: false 169 discount: false
@@ -561,3 +561,7 @@ @@ -561,3 +561,7 @@
561 Indices start at 0. 561 Indices start at 0.
562 562
563 # ---------------------------------------------------------------------------- 563 # ----------------------------------------------------------------------------
  564 +- type: generator
  565 + ref: tut-generator
  566 + script: generators/generate-question.py
  567 + args: [1, 100]
perguntations/app.py
  1 +'''
  2 +Main application module
  3 +'''
  4 +
1 5
2 # python standard libraries 6 # python standard libraries
3 -from os import path  
4 -import logging  
5 -from contextlib import contextmanager # `with` statement in db sessions  
6 import asyncio 7 import asyncio
  8 +from contextlib import contextmanager # `with` statement in db sessions
  9 +import json
  10 +import logging
  11 +from os import path
7 12
8 # user installed packages 13 # user installed packages
9 import bcrypt 14 import bcrypt
10 -import json  
11 from sqlalchemy import create_engine 15 from sqlalchemy import create_engine
12 from sqlalchemy.orm import sessionmaker 16 from sqlalchemy.orm import sessionmaker
13 17
@@ -21,43 +25,50 @@ logger = logging.getLogger(__name__) @@ -21,43 +25,50 @@ logger = logging.getLogger(__name__)
21 25
22 # ============================================================================ 26 # ============================================================================
23 class AppException(Exception): 27 class AppException(Exception):
24 - pass 28 + '''Exception raised in this module'''
25 29
26 30
27 # ============================================================================ 31 # ============================================================================
28 # helper functions 32 # helper functions
29 # ============================================================================ 33 # ============================================================================
30 async def check_password(try_pw, password): 34 async def check_password(try_pw, password):
  35 + '''check password in executor'''
31 try_pw = try_pw.encode('utf-8') 36 try_pw = try_pw.encode('utf-8')
32 loop = asyncio.get_running_loop() 37 loop = asyncio.get_running_loop()
33 hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, password) 38 hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, password)
34 return password == hashed 39 return password == hashed
35 40
36 41
37 -async def hash_password(pw):  
38 - pw = pw.encode('utf-8') 42 +async def hash_password(password):
  43 + '''hash password in executor'''
39 loop = asyncio.get_running_loop() 44 loop = asyncio.get_running_loop()
40 - r = await loop.run_in_executor(None, bcrypt.hashpw, pw, bcrypt.gensalt())  
41 - return r 45 + return await loop.run_in_executor(None, bcrypt.hashpw,
  46 + password.encode('utf-8'),
  47 + bcrypt.gensalt())
42 48
43 49
44 # ============================================================================ 50 # ============================================================================
45 -# Application  
46 -# state:  
47 -# self.Session  
48 -# self.online - {uid:  
49 -# {'student':{...}, 'test': {...}},  
50 -# ...  
51 -# }  
52 -# self.allowd - {'123', '124', ...}  
53 -# self.testfactory - TestFactory  
54 # ============================================================================ 51 # ============================================================================
55 -class App(object): 52 +class App():
  53 + '''
  54 + This is the main application
  55 + state:
  56 + self.Session
  57 + self.online - {uid:
  58 + {'student':{...}, 'test': {...}},
  59 + ...
  60 + }
  61 + self.allowd - {'123', '124', ...}
  62 + self.testfactory - TestFactory
  63 + '''
  64 +
56 # ------------------------------------------------------------------------ 65 # ------------------------------------------------------------------------
57 - # helper to manage db sessions using the `with` statement, for example  
58 - # with self.db_session() as s: s.query(...)  
59 @contextmanager 66 @contextmanager
60 def db_session(self): 67 def db_session(self):
  68 + '''
  69 + helper to manage db sessions using the `with` statement, for example:
  70 + with self.db_session() as s: s.query(...)
  71 + '''
61 session = self.Session() 72 session = self.Session()
62 try: 73 try:
63 yield session 74 yield session
@@ -73,15 +84,15 @@ class App(object): @@ -73,15 +84,15 @@ class App(object):
73 self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...} 84 self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...}
74 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere 85 self.allowed = set([]) # '0' is hardcoded to allowed elsewhere
75 86
76 - logger.info(f'Loading test configuration "{conf["testfile"]}".') 87 + logger.info('Loading test configuration "%s".', conf["testfile"])
77 testconf = load_yaml(conf['testfile']) 88 testconf = load_yaml(conf['testfile'])
78 testconf.update(conf) # command line options override configuration 89 testconf.update(conf) # command line options override configuration
79 90
80 # start test factory 91 # start test factory
81 try: 92 try:
82 self.testfactory = TestFactory(testconf) 93 self.testfactory = TestFactory(testconf)
83 - except TestFactoryException as e:  
84 - logger.critical(e) 94 + except TestFactoryException as exc:
  95 + logger.critical(exc)
85 raise AppException('Failed to create test factory!') 96 raise AppException('Failed to create test factory!')
86 else: 97 else:
87 logger.info('No errors found. Test factory ready.') 98 logger.info('No errors found. Test factory ready.')
@@ -92,12 +103,12 @@ class App(object): @@ -92,12 +103,12 @@ class App(object):
92 engine = create_engine(database, echo=False) 103 engine = create_engine(database, echo=False)
93 self.Session = sessionmaker(bind=engine) 104 self.Session = sessionmaker(bind=engine)
94 try: 105 try:
95 - with self.db_session() as s:  
96 - n = s.query(Student).filter(Student.id != '0').count() 106 + with self.db_session() as sess:
  107 + num = sess.query(Student).filter(Student.id != '0').count()
97 except Exception: 108 except Exception:
98 raise AppException(f'Database unusable {dbfile}.') 109 raise AppException(f'Database unusable {dbfile}.')
99 else: 110 else:
100 - logger.info(f'Database "{dbfile}" has {n} students.') 111 + logger.info('Database "%s" has %s students.', dbfile, num)
101 112
102 # command line option --allow-all 113 # command line option --allow-all
103 if conf['allow_all']: 114 if conf['allow_all']:
@@ -117,15 +128,16 @@ class App(object): @@ -117,15 +128,16 @@ class App(object):
117 128
118 # ------------------------------------------------------------------------ 129 # ------------------------------------------------------------------------
119 async def login(self, uid, try_pw): 130 async def login(self, uid, try_pw):
  131 + '''login authentication'''
120 if uid not in self.allowed and uid != '0': # not allowed 132 if uid not in self.allowed and uid != '0': # not allowed
121 - logger.warning(f'Student {uid}: not allowed to login.') 133 + logger.warning('Student %s: not allowed to login.', uid)
122 return False 134 return False
123 135
124 # get name+password from db 136 # get name+password from db
125 - with self.db_session() as s:  
126 - name, password = s.query(Student.name, Student.password)\  
127 - .filter_by(id=uid)\  
128 - .one() 137 + with self.db_session() as sess:
  138 + name, password = sess.query(Student.name, Student.password)\
  139 + .filter_by(id=uid)\
  140 + .one()
129 141
130 # first login updates the password 142 # first login updates the password
131 if password == '': # update password on first login 143 if password == '': # update password on first login
@@ -137,104 +149,112 @@ class App(object): @@ -137,104 +149,112 @@ class App(object):
137 if pw_ok: # success 149 if pw_ok: # success
138 self.allowed.discard(uid) # remove from set of allowed students 150 self.allowed.discard(uid) # remove from set of allowed students
139 if uid in self.online: 151 if uid in self.online:
140 - logger.warning(f'Student {uid}: already logged in.') 152 + logger.warning('Student %s: already logged in.', uid)
141 else: # make student online 153 else: # make student online
142 self.online[uid] = {'student': {'name': name, 'number': uid}} 154 self.online[uid] = {'student': {'name': name, 'number': uid}}
143 - logger.info(f'Student {uid}: logged in.') 155 + logger.info('Student %s: logged in.', uid)
144 return True 156 return True
145 - else: # wrong password  
146 - logger.info(f'Student {uid}: wrong password.')  
147 - return False 157 + # wrong password
  158 + logger.info('Student %s: wrong password.', uid)
  159 + return False
148 160
149 # ------------------------------------------------------------------------ 161 # ------------------------------------------------------------------------
150 def logout(self, uid): 162 def logout(self, uid):
  163 + '''student logout'''
151 self.online.pop(uid, None) # remove from dict if exists 164 self.online.pop(uid, None) # remove from dict if exists
152 - logger.info(f'Student {uid}: logged out.') 165 + logger.info('Student %s: logged out.', uid)
153 166
154 # ------------------------------------------------------------------------ 167 # ------------------------------------------------------------------------
155 async def generate_test(self, uid): 168 async def generate_test(self, uid):
  169 + '''generate a test for a given student'''
156 if uid in self.online: 170 if uid in self.online:
157 - logger.info(f'Student {uid}: generating new test.') 171 + logger.info('Student %s: generating new test.', uid)
158 student_id = self.online[uid]['student'] # {number, name} 172 student_id = self.online[uid]['student'] # {number, name}
159 test = await self.testfactory.generate(student_id) 173 test = await self.testfactory.generate(student_id)
160 self.online[uid]['test'] = test 174 self.online[uid]['test'] = test
161 - logger.info(f'Student {uid}: test is ready.') 175 + logger.info('Student %s: test is ready.', uid)
162 return self.online[uid]['test'] 176 return self.online[uid]['test']
163 - else:  
164 - # this implies an error in the code. should never be here!  
165 - logger.critical(f'Student {uid}: offline, can\'t generate test') 177 +
  178 + # this implies an error in the code. should never be here!
  179 + logger.critical('Student %s: offline, can\'t generate test', uid)
166 180
167 # ------------------------------------------------------------------------ 181 # ------------------------------------------------------------------------
168 - # ans is a dictionary {question_index: answer, ...}  
169 - # for example: {0:'hello', 1:[1,2]}  
170 async def correct_test(self, uid, ans): 182 async def correct_test(self, uid, ans):
171 - t = self.online[uid]['test'] 183 + '''
  184 + Corrects test
  185 +
  186 + ans is a dictionary {question_index: answer, ...}
  187 + for example: {0:'hello', 1:[1,2]}
  188 + '''
  189 + test = self.online[uid]['test']
172 190
173 # --- submit answers and correct test 191 # --- submit answers and correct test
174 - t.update_answers(ans)  
175 - logger.info(f'Student {uid}: {len(ans)} answers submitted.') 192 + test.update_answers(ans)
  193 + logger.info('Student %s: %d answers submitted.', uid, len(ans))
176 194
177 - grade = await t.correct()  
178 - logger.info(f'Student {uid}: grade = {grade:5.3} points.') 195 + grade = await test.correct()
  196 + logger.info('Student %s: grade = %g points.', uid, grade)
179 197
180 # --- save test in JSON format 198 # --- save test in JSON format
181 - fields = (uid, t['ref'], str(t['finish_time']))  
182 - fname = ' -- '.join(fields) + '.json'  
183 - fpath = path.join(t['answers_dir'], fname)  
184 - with open(path.expanduser(fpath), 'w') as f: 199 + fields = (uid, test['ref'], str(test['finish_time']))
  200 + fname = '--'.join(fields) + '.json'
  201 + fpath = path.join(test['answers_dir'], fname)
  202 + with open(path.expanduser(fpath), 'w') as file:
185 # default=str required for datetime objects 203 # default=str required for datetime objects
186 - json.dump(t, f, indent=2, default=str)  
187 - logger.info(f'Student {uid}: saved JSON.') 204 + json.dump(test, file, indent=2, default=str)
  205 + logger.info('Student %s: saved JSON.', uid)
188 206
189 # --- insert test and questions into database 207 # --- insert test and questions into database
190 - with self.db_session() as s:  
191 - s.add(Test(  
192 - ref=t['ref'],  
193 - title=t['title'],  
194 - grade=t['grade'],  
195 - starttime=str(t['start_time']),  
196 - finishtime=str(t['finish_time']), 208 + with self.db_session() as sess:
  209 + sess.add(Test(
  210 + ref=test['ref'],
  211 + title=test['title'],
  212 + grade=test['grade'],
  213 + starttime=str(test['start_time']),
  214 + finishtime=str(test['finish_time']),
197 filename=fpath, 215 filename=fpath,
198 student_id=uid, 216 student_id=uid,
199 - state=t['state'], 217 + state=test['state'],
200 comment='')) 218 comment=''))
201 - s.add_all([Question( 219 + sess.add_all([Question(
202 ref=q['ref'], 220 ref=q['ref'],
203 grade=q['grade'], 221 grade=q['grade'],
204 - starttime=str(t['start_time']),  
205 - finishtime=str(t['finish_time']), 222 + starttime=str(test['start_time']),
  223 + finishtime=str(test['finish_time']),
206 student_id=uid, 224 student_id=uid,
207 - test_id=t['ref']) for q in t['questions'] if 'grade' in q]) 225 + test_id=test['ref'])
  226 + for q in test['questions'] if 'grade' in q])
208 227
209 - logger.info(f'Student {uid}: database updated.') 228 + logger.info('Student %s: database updated.', uid)
210 return grade 229 return grade
211 230
212 # ------------------------------------------------------------------------ 231 # ------------------------------------------------------------------------
213 def giveup_test(self, uid): 232 def giveup_test(self, uid):
214 - t = self.online[uid]['test']  
215 - t.giveup() 233 + '''giveup test - not used??'''
  234 + test = self.online[uid]['test']
  235 + test.giveup()
216 236
217 # save JSON with the test 237 # save JSON with the test
218 - fields = (t['student']['number'], t['ref'], str(t['finish_time']))  
219 - fname = ' -- '.join(fields) + '.json'  
220 - fpath = path.join(t['answers_dir'], fname)  
221 - t.save_json(fpath) 238 + fields = (test['student']['number'], test['ref'],
  239 + str(test['finish_time']))
  240 + fname = '--'.join(fields) + '.json'
  241 + fpath = path.join(test['answers_dir'], fname)
  242 + test.save_json(fpath)
222 243
223 # insert test into database 244 # insert test into database
224 - with self.db_session() as s:  
225 - s.add(Test(  
226 - ref=t['ref'],  
227 - title=t['title'],  
228 - grade=t['grade'],  
229 - starttime=str(t['start_time']),  
230 - finishtime=str(t['finish_time']),  
231 - filename=fpath,  
232 - student_id=t['student']['number'],  
233 - state=t['state'],  
234 - comment=''))  
235 -  
236 - logger.info(f'Student {uid}: gave up.')  
237 - return t 245 + with self.db_session() as sess:
  246 + sess.add(Test(ref=test['ref'],
  247 + title=test['title'],
  248 + grade=test['grade'],
  249 + starttime=str(test['start_time']),
  250 + finishtime=str(test['finish_time']),
  251 + filename=fpath,
  252 + student_id=test['student']['number'],
  253 + state=test['state'],
  254 + comment=''))
  255 +
  256 + logger.info('Student %s: gave up.', uid)
  257 + return test
238 258
239 # ------------------------------------------------------------------------ 259 # ------------------------------------------------------------------------
240 260
@@ -243,52 +263,58 @@ class App(object): @@ -243,52 +263,58 @@ class App(object):
243 # return self.online[uid]['student']['name'] 263 # return self.online[uid]['student']['name']
244 264
245 def get_student_test(self, uid, default=None): 265 def get_student_test(self, uid, default=None):
  266 + '''get test from online student'''
246 return self.online[uid].get('test', default) 267 return self.online[uid].get('test', default)
247 268
248 # def get_questions_dir(self): 269 # def get_questions_dir(self):
249 # return self.testfactory['questions_dir'] 270 # return self.testfactory['questions_dir']
250 271
251 def get_student_grades_from_all_tests(self, uid): 272 def get_student_grades_from_all_tests(self, uid):
252 - with self.db_session() as s:  
253 - return s.query(Test.title, Test.grade, Test.finishtime)\  
254 - .filter_by(student_id=uid)\  
255 - .order_by(Test.finishtime) 273 + '''get grades of student from all tests'''
  274 + with self.db_session() as sess:
  275 + return sess.query(Test.title, Test.grade, Test.finishtime)\
  276 + .filter_by(student_id=uid)\
  277 + .order_by(Test.finishtime)
256 278
257 def get_json_filename_of_test(self, test_id): 279 def get_json_filename_of_test(self, test_id):
258 - with self.db_session() as s:  
259 - return s.query(Test.filename)\  
260 - .filter_by(id=test_id)\  
261 - .scalar() 280 + '''get JSON filename from database given the test_id'''
  281 + with self.db_session() as sess:
  282 + return sess.query(Test.filename)\
  283 + .filter_by(id=test_id)\
  284 + .scalar()
262 285
263 def get_all_students(self): 286 def get_all_students(self):
264 - with self.db_session() as s:  
265 - return s.query(Student.id, Student.name, Student.password)\  
266 - .filter(Student.id != '0')\  
267 - .order_by(Student.id) 287 + '''get all students from database'''
  288 + with self.db_session() as sess:
  289 + return sess.query(Student.id, Student.name, Student.password)\
  290 + .filter(Student.id != '0')\
  291 + .order_by(Student.id)
268 292
269 def get_student_grades_from_test(self, uid, testid): 293 def get_student_grades_from_test(self, uid, testid):
270 - with self.db_session() as s:  
271 - return s.query(Test.grade, Test.finishtime, Test.id)\  
272 - .filter_by(student_id=uid)\  
273 - .filter_by(ref=testid)\  
274 - .all() 294 + '''get grades of student for a given testid'''
  295 + with self.db_session() as sess:
  296 + return sess.query(Test.grade, Test.finishtime, Test.id)\
  297 + .filter_by(student_id=uid)\
  298 + .filter_by(ref=testid)\
  299 + .all()
275 300
276 def get_students_state(self): 301 def get_students_state(self):
  302 + '''get list of states of every student'''
277 return [{ 303 return [{
278 - 'uid': uid,  
279 - 'name': name,  
280 - 'allowed': uid in self.allowed,  
281 - 'online': uid in self.online,  
282 - 'start_time': self.online.get(uid, {}).get('test', {})  
283 - .get('start_time', ''),  
284 - 'password_defined': pw != '',  
285 - 'grades': self.get_student_grades_from_test(  
286 - uid,  
287 - self.testfactory['ref']  
288 - ),  
289 -  
290 - # 'focus': self.online.get(uid, {}).get('student', {}).get('focus', True), # FIXME  
291 - } for uid, name, pw in self.get_all_students()] 304 + 'uid': uid,
  305 + 'name': name,
  306 + 'allowed': uid in self.allowed,
  307 + 'online': uid in self.online,
  308 + 'start_time': self.online.get(uid, {}).get('test', {})
  309 + .get('start_time', ''),
  310 + 'password_defined': pw != '',
  311 + 'grades': self.get_student_grades_from_test(
  312 + uid,
  313 + self.testfactory['ref']
  314 + ),
  315 +
  316 + # 'focus': self.online.get(uid, {}).get('student', {}).get('focus', True), # FIXME
  317 + } for uid, name, pw in self.get_all_students()]
292 318
293 # def get_allowed_students(self): 319 # def get_allowed_students(self):
294 # # set of 'uid' allowed to login 320 # # set of 'uid' allowed to login
@@ -303,26 +329,30 @@ class App(object): @@ -303,26 +329,30 @@ class App(object):
303 329
304 # --- helpers (change state) 330 # --- helpers (change state)
305 def allow_student(self, uid): 331 def allow_student(self, uid):
  332 + '''allow a single student to login'''
306 self.allowed.add(uid) 333 self.allowed.add(uid)
307 - logger.info(f'Student {uid}: allowed to login.') 334 + logger.info('Student %s: allowed to login.', uid)
308 335
309 def deny_student(self, uid): 336 def deny_student(self, uid):
  337 + '''deny a single student to login'''
310 self.allowed.discard(uid) 338 self.allowed.discard(uid)
311 - logger.info(f'Student {uid}: denied to login') 339 + logger.info('Student %s: denied to login', uid)
312 340
313 - async def update_student_password(self, uid, pw=''):  
314 - if pw:  
315 - pw = await hash_password(pw)  
316 - with self.db_session() as s:  
317 - student = s.query(Student).filter_by(id=uid).one()  
318 - student.password = pw  
319 - logger.info(f'Student {uid}: password updated.') 341 + async def update_student_password(self, uid, password=''):
  342 + '''change password on the database'''
  343 + if password:
  344 + password = await hash_password(password)
  345 + with self.db_session() as sess:
  346 + student = sess.query(Student).filter_by(id=uid).one()
  347 + student.password = password
  348 + logger.info('Student %s: password updated.', uid)
320 349
321 def insert_new_student(self, uid, name): 350 def insert_new_student(self, uid, name):
  351 + '''insert new student into the database'''
322 try: 352 try:
323 - with self.db_session() as s:  
324 - s.add(Student(id=uid, name=name, password='')) 353 + with self.db_session() as sess:
  354 + sess.add(Student(id=uid, name=name, password=''))
325 except Exception: 355 except Exception:
326 - logger.error(f'Insert failed: student {uid} already exists.') 356 + logger.error('Insert failed: student %s already exists.', uid)
327 else: 357 else:
328 - logger.info(f'New student inserted: {uid}, {name}') 358 + logger.info('New student inserted: %s, %s', uid, name)
perguntations/initdb.py
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
  3 +'''
  4 +Commandline utilizty to initialize and update student database
  5 +'''
  6 +
3 # base 7 # base
4 import csv 8 import csv
5 import argparse 9 import argparse
@@ -16,8 +20,8 @@ from perguntations.models import Base, Student @@ -16,8 +20,8 @@ from perguntations.models import Base, Student
16 20
17 21
18 # =========================================================================== 22 # ===========================================================================
19 -# Parse command line options  
20 def parse_commandline_arguments(): 23 def parse_commandline_arguments():
  24 + '''Parse command line options'''
21 parser = argparse.ArgumentParser( 25 parser = argparse.ArgumentParser(
22 formatter_class=argparse.ArgumentDefaultsHelpFormatter, 26 formatter_class=argparse.ArgumentDefaultsHelpFormatter,
23 description='Insert new users into a database. Users can be imported ' 27 description='Insert new users into a database. Users can be imported '
@@ -65,9 +69,11 @@ def parse_commandline_arguments(): @@ -65,9 +69,11 @@ def parse_commandline_arguments():
65 69
66 70
67 # =========================================================================== 71 # ===========================================================================
68 -# SIIUE names have alien strings like "(TE)" and are sometimes capitalized  
69 -# We remove them so that students dont keep asking what it means  
70 def get_students_from_csv(filename): 72 def get_students_from_csv(filename):
  73 + '''
  74 + SIIUE names have alien strings like "(TE)" and are sometimes capitalized
  75 + We remove them so that students dont keep asking what it means
  76 + '''
71 csv_settings = { 77 csv_settings = {
72 'delimiter': ';', 78 'delimiter': ';',
73 'quotechar': '"', 79 'quotechar': '"',
@@ -75,8 +81,8 @@ def get_students_from_csv(filename): @@ -75,8 +81,8 @@ def get_students_from_csv(filename):
75 } 81 }
76 82
77 try: 83 try:
78 - with open(filename, encoding='iso-8859-1') as f:  
79 - csvreader = csv.DictReader(f, **csv_settings) 84 + with open(filename, encoding='iso-8859-1') as file:
  85 + csvreader = csv.DictReader(file, **csv_settings)
80 students = [{ 86 students = [{
81 'uid': s['N.º'], 87 'uid': s['N.º'],
82 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip()) 88 'name': capwords(re.sub(r'\(.*\)', '', s['Nome']).strip())
@@ -92,20 +98,22 @@ def get_students_from_csv(filename): @@ -92,20 +98,22 @@ def get_students_from_csv(filename):
92 98
93 99
94 # =========================================================================== 100 # ===========================================================================
95 -# replace password by hash for a single student  
96 -def hashpw(student, pw=None): 101 +def hashpw(student, password=None):
  102 + '''replace password by hash for a single student'''
97 print('.', end='', flush=True) 103 print('.', end='', flush=True)
98 - if pw is None: 104 + if password is None:
99 student['pw'] = '' 105 student['pw'] = ''
100 else: 106 else:
101 - student['pw'] = bcrypt.hashpw(pw.encode('utf-8'), bcrypt.gensalt()) 107 + student['pw'] = bcrypt.hashpw(password.encode('utf-8'),
  108 + bcrypt.gensalt())
102 109
103 110
104 # =========================================================================== 111 # ===========================================================================
105 def insert_students_into_db(session, students): 112 def insert_students_into_db(session, students):
  113 + '''insert list of students into the database'''
106 try: 114 try:
107 session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw']) 115 session.add_all([Student(id=s['uid'], name=s['name'], password=s['pw'])
108 - for s in students]) 116 + for s in students])
109 session.commit() 117 session.commit()
110 118
111 except sa.exc.IntegrityError: 119 except sa.exc.IntegrityError:
@@ -115,33 +123,33 @@ def insert_students_into_db(session, students): @@ -115,33 +123,33 @@ def insert_students_into_db(session, students):
115 123
116 # ============================================================================ 124 # ============================================================================
117 def show_students_in_database(session, verbose=False): 125 def show_students_in_database(session, verbose=False):
118 - try:  
119 - users = session.query(Student).all()  
120 - except Exception:  
121 - raise 126 + '''get students from database'''
  127 + users = session.query(Student).all()
  128 +
  129 + total_users = len(users)
  130 + print('Registered users:')
  131 + if total_users == 0:
  132 + print(' -- none --')
122 else: 133 else:
123 - n = len(users)  
124 - print(f'Registered users:')  
125 - if n == 0:  
126 - print(' -- none --') 134 + users.sort(key=lambda u: f'{u.id:>12}') # sort by number
  135 + if verbose:
  136 + for user in users:
  137 + print(f'{user.id:>12} {user.name}')
127 else: 138 else:
128 - users.sort(key=lambda u: f'{u.id:>12}') # sort by number  
129 - if verbose:  
130 - for u in users:  
131 - print(f'{u.id:>12} {u.name}')  
132 - else:  
133 - print(f'{users[0].id:>12} {users[0].name}')  
134 - if n > 1:  
135 - print(f'{users[1].id:>12} {users[1].name}')  
136 - if n > 3:  
137 - print(' | |')  
138 - if n > 2:  
139 - print(f'{users[-1].id:>12} {users[-1].name}')  
140 - print(f'Total: {n}.') 139 + print(f'{users[0].id:>12} {users[0].name}')
  140 + if total_users > 1:
  141 + print(f'{users[1].id:>12} {users[1].name}')
  142 + if total_users > 3:
  143 + print(' | |')
  144 + if total_users > 2:
  145 + print(f'{users[-1].id:>12} {users[-1].name}')
  146 + print(f'Total: {total_users}.')
141 147
142 148
143 # ============================================================================ 149 # ============================================================================
144 def main(): 150 def main():
  151 + '''insert, update, show students from database'''
  152 +
145 args = parse_commandline_arguments() 153 args = parse_commandline_arguments()
146 154
147 # --- make list of students to insert/update 155 # --- make list of students to insert/update
@@ -162,27 +170,27 @@ def main(): @@ -162,27 +170,27 @@ def main():
162 170
163 # --- password hashing 171 # --- password hashing
164 if students: 172 if students:
165 - print(f'Generating password hashes', end='') 173 + print('Generating password hashes', end='')
166 with ThreadPoolExecutor() as executor: # hashing in parallel 174 with ThreadPoolExecutor() as executor: # hashing in parallel
167 executor.map(lambda s: hashpw(s, args.pw), students) 175 executor.map(lambda s: hashpw(s, args.pw), students)
168 print() 176 print()
169 177
170 # --- database stuff 178 # --- database stuff
171 - print(f'Using database: ', args.db) 179 + print(f'Using database: {args.db}')
172 engine = sa.create_engine(f'sqlite:///{args.db}', echo=False) 180 engine = sa.create_engine(f'sqlite:///{args.db}', echo=False)
173 Base.metadata.create_all(engine) # Criates schema if needed 181 Base.metadata.create_all(engine) # Criates schema if needed
174 - Session = sa.orm.sessionmaker(bind=engine)  
175 - session = Session() 182 + SessionMaker = sa.orm.sessionmaker(bind=engine)
  183 + session = SessionMaker()
176 184
177 if students: 185 if students:
178 print(f'Inserting {len(students)}') 186 print(f'Inserting {len(students)}')
179 insert_students_into_db(session, students) 187 insert_students_into_db(session, students)
180 188
181 - for s in args.update:  
182 - print(f'Updating password of: {s}')  
183 - u = session.query(Student).get(s)  
184 - pw = (args.pw or s).encode('utf-8')  
185 - u.password = bcrypt.hashpw(pw, bcrypt.gensalt()) 189 + for student_id in args.update:
  190 + print(f'Updating password of: {student_id}')
  191 + student = session.query(Student).get(student_id)
  192 + password = (args.pw or student_id).encode('utf-8')
  193 + student.password = bcrypt.hashpw(password, bcrypt.gensalt())
186 session.commit() 194 session.commit()
187 195
188 show_students_in_database(session, args.verbose) 196 show_students_in_database(session, args.verbose)
perguntations/main.py
1 #!/usr/bin/env python3 1 #!/usr/bin/env python3
2 2
  3 +'''
  4 +Main file that starts the application and the web server
  5 +'''
  6 +
  7 +
3 # python standard library 8 # python standard library
4 import argparse 9 import argparse
5 import logging 10 import logging
@@ -10,7 +15,7 @@ import sys @@ -10,7 +15,7 @@ import sys
10 # from typing import Any, Dict 15 # from typing import Any, Dict
11 16
12 # this project 17 # this project
13 -from .app import App 18 +from .app import App, AppException
14 from .serve import run_webserver 19 from .serve import run_webserver
15 from .tools import load_yaml 20 from .tools import load_yaml
16 from . import APP_NAME, APP_VERSION 21 from . import APP_NAME, APP_VERSION
@@ -125,7 +130,7 @@ def main(): @@ -125,7 +130,7 @@ def main():
125 # testapp = App(config) 130 # testapp = App(config)
126 try: 131 try:
127 testapp = App(config) 132 testapp = App(config)
128 - except Exception: 133 + except AppException:
129 logging.critical('Failed to start application.') 134 logging.critical('Failed to start application.')
130 sys.exit(-1) 135 sys.exit(-1)
131 136
perguntations/models.py
  1 +'''
  2 +Database tables
  3 +'''
  4 +
1 5
2 from sqlalchemy import Column, ForeignKey, Integer, Float, String 6 from sqlalchemy import Column, ForeignKey, Integer, Float, String
3 from sqlalchemy.ext.declarative import declarative_base 7 from sqlalchemy.ext.declarative import declarative_base
@@ -11,6 +15,7 @@ Base = declarative_base() @@ -11,6 +15,7 @@ Base = declarative_base()
11 15
12 # ---------------------------------------------------------------------------- 16 # ----------------------------------------------------------------------------
13 class Student(Base): 17 class Student(Base):
  18 + '''Student table'''
14 __tablename__ = 'students' 19 __tablename__ = 'students'
15 id = Column(String, primary_key=True) 20 id = Column(String, primary_key=True)
16 name = Column(String) 21 name = Column(String)
@@ -29,6 +34,7 @@ class Student(Base): @@ -29,6 +34,7 @@ class Student(Base):
29 34
30 # ---------------------------------------------------------------------------- 35 # ----------------------------------------------------------------------------
31 class Test(Base): 36 class Test(Base):
  37 + '''Test table'''
32 __tablename__ = 'tests' 38 __tablename__ = 'tests'
33 id = Column(Integer, primary_key=True) # auto_increment 39 id = Column(Integer, primary_key=True) # auto_increment
34 ref = Column(String) 40 ref = Column(String)
@@ -61,6 +67,7 @@ class Test(Base): @@ -61,6 +67,7 @@ class Test(Base):
61 67
62 # --------------------------------------------------------------------------- 68 # ---------------------------------------------------------------------------
63 class Question(Base): 69 class Question(Base):
  70 + '''Question table'''
64 __tablename__ = 'questions' 71 __tablename__ = 'questions'
65 id = Column(Integer, primary_key=True) # auto_increment 72 id = Column(Integer, primary_key=True) # auto_increment
66 ref = Column(String) 73 ref = Column(String)
perguntations/parser_markdown.py
  1 +'''
  2 +Parse markdown and generate HTML
  3 +Includes support for LaTeX formulas
  4 +'''
  5 +
  6 +
1 # python standard library 7 # python standard library
2 import logging 8 import logging
3 import re 9 import re
@@ -33,18 +39,19 @@ class MathBlockLexer(mistune.BlockLexer): @@ -33,18 +39,19 @@ class MathBlockLexer(mistune.BlockLexer):
33 rules = MathBlockGrammar() 39 rules = MathBlockGrammar()
34 super().__init__(rules, **kwargs) 40 super().__init__(rules, **kwargs)
35 41
36 - def parse_block_math(self, m):  
37 - """Parse a $$math$$ block""" 42 + def parse_block_math(self, math):
  43 + '''Parse a $$math$$ block'''
38 self.tokens.append({ 44 self.tokens.append({
39 'type': 'block_math', 45 'type': 'block_math',
40 - 'text': m.group(1) 46 + 'text': math.group(1)
41 }) 47 })
42 48
43 - def parse_latex_environment(self, m): 49 + def parse_latex_environment(self, math):
  50 + '''Parse latex environment in formula'''
44 self.tokens.append({ 51 self.tokens.append({
45 'type': 'latex_environment', 52 'type': 'latex_environment',
46 - 'name': m.group(1),  
47 - 'text': m.group(2) 53 + 'name': math.group(1),
  54 + 'text': math.group(2)
48 }) 55 })
49 56
50 57
@@ -62,11 +69,11 @@ class MathInlineLexer(mistune.InlineLexer): @@ -62,11 +69,11 @@ class MathInlineLexer(mistune.InlineLexer):
62 rules = MathInlineGrammar() 69 rules = MathInlineGrammar()
63 super().__init__(renderer, rules, **kwargs) 70 super().__init__(renderer, rules, **kwargs)
64 71
65 - def output_math(self, m):  
66 - return self.renderer.inline_math(m.group(1)) 72 + def output_math(self, math):
  73 + return self.renderer.inline_math(math.group(1))
67 74
68 - def output_block_math(self, m):  
69 - return self.renderer.block_math(m.group(1)) 75 + def output_block_math(self, math):
  76 + return self.renderer.block_math(math.group(1))
70 77
71 78
72 class MarkdownWithMath(mistune.Markdown): 79 class MarkdownWithMath(mistune.Markdown):
@@ -104,6 +111,7 @@ class HighlightRenderer(mistune.Renderer): @@ -104,6 +111,7 @@ class HighlightRenderer(mistune.Renderer):
104 + header + '</thead><tbody>' + body + '</tbody></table>' 111 + header + '</thead><tbody>' + body + '</tbody></table>'
105 112
106 def image(self, src, title, alt): 113 def image(self, src, title, alt):
  114 + '''render image'''
107 alt = mistune.escape(alt, quote=True) 115 alt = mistune.escape(alt, quote=True)
108 if title is not None: 116 if title is not None:
109 if title: # not empty string, show as caption 117 if title: # not empty string, show as caption
@@ -124,22 +132,26 @@ class HighlightRenderer(mistune.Renderer): @@ -124,22 +132,26 @@ class HighlightRenderer(mistune.Renderer):
124 </div> 132 </div>
125 ''' 133 '''
126 134
127 - else: # title indefined, show as inline image  
128 - return f'''  
129 - <img src="/file?ref={self.qref}&image={src}"  
130 - class="figure-img img-fluid" alt="{alt}" title="{title}">  
131 - ''' 135 + # title indefined, show as inline image
  136 + return f'''
  137 + <img src="/file?ref={self.qref}&image={src}"
  138 + class="figure-img img-fluid" alt="{alt}" title="{title}">
  139 + '''
132 140
133 # Pass math through unaltered - mathjax does the rendering in the browser 141 # Pass math through unaltered - mathjax does the rendering in the browser
134 def block_math(self, text): 142 def block_math(self, text):
  143 + '''bypass block math'''
135 return fr'$$ {text} $$' 144 return fr'$$ {text} $$'
136 145
137 def latex_environment(self, name, text): 146 def latex_environment(self, name, text):
  147 + '''bypass latex environment'''
138 return fr'\begin{{{name}}} {text} \end{{{name}}}' 148 return fr'\begin{{{name}}} {text} \end{{{name}}}'
139 149
140 def inline_math(self, text): 150 def inline_math(self, text):
  151 + '''bypass inline math'''
141 return fr'$$$ {text} $$$' 152 return fr'$$$ {text} $$$'
142 153
143 154
144 def md_to_html(qref='.'): 155 def md_to_html(qref='.'):
  156 + '''markdown to html interface'''
145 return MarkdownWithMath(HighlightRenderer(qref=qref)) 157 return MarkdownWithMath(HighlightRenderer(qref=qref))
perguntations/serve.py
@@ -12,12 +12,10 @@ import sys @@ -12,12 +12,10 @@ import sys
12 import base64 12 import base64
13 import uuid 13 import uuid
14 import logging.config 14 import logging.config
15 -# import argparse  
16 import mimetypes 15 import mimetypes
17 import signal 16 import signal
18 import functools 17 import functools
19 import json 18 import json
20 -# import ssl  
21 19
22 # user installed libraries 20 # user installed libraries
23 import tornado.ioloop 21 import tornado.ioloop
@@ -30,9 +28,10 @@ from perguntations.parser_markdown import md_to_html @@ -30,9 +28,10 @@ from perguntations.parser_markdown import md_to_html
30 28
31 29
32 # ---------------------------------------------------------------------------- 30 # ----------------------------------------------------------------------------
33 -# Web Application. Routes to handler classes.  
34 -# ----------------------------------------------------------------------------  
35 class WebApplication(tornado.web.Application): 31 class WebApplication(tornado.web.Application):
  32 + '''
  33 + Web Application. Routes to handler classes.
  34 + '''
36 def __init__(self, testapp, debug=False): 35 def __init__(self, testapp, debug=False):
37 handlers = [ 36 handlers = [
38 (r'/login', LoginHandler), 37 (r'/login', LoginHandler),
@@ -73,9 +72,11 @@ def admin_only(func): @@ -73,9 +72,11 @@ def admin_only(func):
73 72
74 73
75 # ---------------------------------------------------------------------------- 74 # ----------------------------------------------------------------------------
76 -# Base handler. Other handlers will inherit this one.  
77 -# ----------------------------------------------------------------------------  
78 class BaseHandler(tornado.web.RequestHandler): 75 class BaseHandler(tornado.web.RequestHandler):
  76 + '''
  77 + Base handler. Other handlers will inherit this one.
  78 + '''
  79 +
79 @property 80 @property
80 def testapp(self): 81 def testapp(self):
81 ''' 82 '''
@@ -152,8 +153,10 @@ class BaseHandler(tornado.web.RequestHandler): @@ -152,8 +153,10 @@ class BaseHandler(tornado.web.RequestHandler):
152 # AdminSocketHandler.send_updates(chat) # send to clients 153 # AdminSocketHandler.send_updates(chat) # send to clients
153 154
154 155
155 -# --- ADMIN ------------------------------------------------------------------ 156 +# ----------------------------------------------------------------------------
156 class AdminHandler(BaseHandler): 157 class AdminHandler(BaseHandler):
  158 + '''Handle /admin'''
  159 +
157 # SUPPORTED_METHODS = ['GET', 'POST'] 160 # SUPPORTED_METHODS = ['GET', 'POST']
158 161
159 @tornado.web.authenticated 162 @tornado.web.authenticated
@@ -209,19 +212,15 @@ class AdminHandler(BaseHandler): @@ -209,19 +212,15 @@ class AdminHandler(BaseHandler):
209 212
210 213
211 # ---------------------------------------------------------------------------- 214 # ----------------------------------------------------------------------------
212 -# /login  
213 -# ----------------------------------------------------------------------------  
214 class LoginHandler(BaseHandler): 215 class LoginHandler(BaseHandler):
  216 + '''Handle /login'''
  217 +
215 def get(self): 218 def get(self):
216 - '''  
217 - Render login page.  
218 - ''' 219 + '''Render login page.'''
219 self.render('login.html', error='') 220 self.render('login.html', error='')
220 221
221 async def post(self): 222 async def post(self):
222 - '''  
223 - Authenticates student (prefix 'l' are removed) and login.  
224 - ''' 223 + '''Authenticates student (prefix 'l' are removed) and login.'''
225 224
226 uid = self.get_body_argument('uid').lstrip('l') 225 uid = self.get_body_argument('uid').lstrip('l')
227 password = self.get_body_argument('pw') 226 password = self.get_body_argument('pw')
@@ -235,14 +234,12 @@ class LoginHandler(BaseHandler): @@ -235,14 +234,12 @@ class LoginHandler(BaseHandler):
235 234
236 235
237 # ---------------------------------------------------------------------------- 236 # ----------------------------------------------------------------------------
238 -# /logout  
239 -# ----------------------------------------------------------------------------  
240 class LogoutHandler(BaseHandler): 237 class LogoutHandler(BaseHandler):
  238 + '''Handle /logout'''
  239 +
241 @tornado.web.authenticated 240 @tornado.web.authenticated
242 def get(self): 241 def get(self):
243 - '''  
244 - Logs out a user.  
245 - ''' 242 + '''Logs out a user.'''
246 self.clear_cookie('user') 243 self.clear_cookie('user')
247 self.redirect('/') 244 self.redirect('/')
248 245
@@ -251,9 +248,10 @@ class LogoutHandler(BaseHandler): @@ -251,9 +248,10 @@ class LogoutHandler(BaseHandler):
251 248
252 249
253 # ---------------------------------------------------------------------------- 250 # ----------------------------------------------------------------------------
254 -# handles root / to redirect students to /test and admininistrator to /admin  
255 -# ----------------------------------------------------------------------------  
256 class RootHandler(BaseHandler): 251 class RootHandler(BaseHandler):
  252 + '''
  253 + Handles / to redirect students and admin to /test and /admin, resp.
  254 + '''
257 255
258 @tornado.web.authenticated 256 @tornado.web.authenticated
259 def get(self): 257 def get(self):
perguntations/test.py
@@ -267,23 +267,16 @@ class TestFactory(dict): @@ -267,23 +267,16 @@ class TestFactory(dict):
267 if nerr > 0: 267 if nerr > 0:
268 logger.error('%s errors found!', nerr) 268 logger.error('%s errors found!', nerr)
269 269
  270 + inherit = {'ref', 'title', 'database', 'answers_dir',
  271 + 'questions_dir', 'files',
  272 + 'duration', 'autosubmit',
  273 + 'scale_min', 'scale_max', 'show_points',
  274 + 'show_ref', 'debug', }
  275 + # NOT INCLUDED: scale_points, testfile, allow_all, review
  276 +
270 return Test({ 277 return Test({
271 - 'ref': self['ref'],  
272 - 'title': self['title'], # title of the test  
273 - 'student': student, # student id  
274 - 'questions': test, # list of Question instances  
275 - 'answers_dir': self['answers_dir'],  
276 - 'duration': self['duration'],  
277 - 'autosubmit': self['autosubmit'],  
278 - 'scale_min': self['scale_min'],  
279 - 'scale_max': self['scale_max'],  
280 - 'show_points': self['show_points'],  
281 - 'show_ref': self['show_ref'],  
282 - 'debug': self['debug'], # required by template test.html  
283 - 'database': self['database'],  
284 - 'questions_dir': self['questions_dir'],  
285 - 'files': self['files'],  
286 - }) 278 + **{'student': student, 'questions': test},
  279 + **{k:self[k] for k in inherit}})
287 280
288 # ------------------------------------------------------------------------ 281 # ------------------------------------------------------------------------
289 def __repr__(self): 282 def __repr__(self):
@@ -323,14 +316,12 @@ class Test(dict): @@ -323,14 +316,12 @@ class Test(dict):
323 # ------------------------------------------------------------------------ 316 # ------------------------------------------------------------------------
324 async def correct(self): 317 async def correct(self):
325 '''Corrects all the answers of the test and computes the final grade''' 318 '''Corrects all the answers of the test and computes the final grade'''
326 -  
327 self['finish_time'] = datetime.now() 319 self['finish_time'] = datetime.now()
328 self['state'] = 'FINISHED' 320 self['state'] = 'FINISHED'
329 grade = 0.0 321 grade = 0.0
330 for question in self['questions']: 322 for question in self['questions']:
331 await question.correct_async() 323 await question.correct_async()
332 grade += question['grade'] * question['points'] 324 grade += question['grade'] * question['points']
333 - # logger.debug(f'Correcting {q["ref"]:>30}: {q["grade"]*100:4.0f}%')  
334 logger.debug('Correcting %30s: %3g%%', 325 logger.debug('Correcting %30s: %3g%%',
335 question["ref"], question["grade"]*100) 326 question["ref"], question["grade"]*100)
336 327
perguntations/tools.py
  1 +'''
  2 +This module contains helper functions to:
  3 +- load yaml files and report errors
  4 +- run external programs (sync and async)
  5 +'''
  6 +
1 7
2 # python standard library 8 # python standard library
3 import asyncio 9 import asyncio
@@ -15,105 +21,119 @@ logger = logging.getLogger(__name__) @@ -15,105 +21,119 @@ logger = logging.getLogger(__name__)
15 21
16 22
17 # --------------------------------------------------------------------------- 23 # ---------------------------------------------------------------------------
18 -# load data from yaml file  
19 -# ---------------------------------------------------------------------------  
20 def load_yaml(filename: str, default: Any = None) -> Any: 24 def load_yaml(filename: str, default: Any = None) -> Any:
  25 + '''load data from yaml file'''
  26 +
21 filename = path.expanduser(filename) 27 filename = path.expanduser(filename)
22 try: 28 try:
23 - f = open(filename, 'r', encoding='utf-8')  
24 - except Exception as e:  
25 - logger.error(e) 29 + file = open(filename, 'r', encoding='utf-8')
  30 + except Exception as exc:
  31 + logger.error(exc)
26 if default is not None: 32 if default is not None:
27 return default 33 return default
28 - else:  
29 - raise 34 + raise
30 35
31 - with f: 36 + with file:
32 try: 37 try:
33 - return yaml.safe_load(f)  
34 - except yaml.YAMLError as e:  
35 - logger.error(str(e).replace('\n', ' ')) 38 + return yaml.safe_load(file)
  39 + except yaml.YAMLError as exc:
  40 + logger.error(str(exc).replace('\n', ' '))
36 if default is not None: 41 if default is not None:
37 return default 42 return default
38 - else:  
39 - raise 43 + raise
40 44
41 45
42 # --------------------------------------------------------------------------- 46 # ---------------------------------------------------------------------------
43 -# Runs a script and returns its stdout parsed as yaml, or None on error.  
44 -# The script is run in another process but this function blocks waiting  
45 -# for its termination.  
46 -# ---------------------------------------------------------------------------  
47 def run_script(script: str, 47 def run_script(script: str,
48 - args: List[str] = [], 48 + args: List[str],
49 stdin: str = '', 49 stdin: str = '',
50 - timeout: int = 2) -> Any:  
51 - 50 + timeout: int = 3) -> Any:
  51 + '''
  52 + Runs a script and returns its stdout parsed as yaml, or None on error.
  53 + The script is run in another process but this function blocks waiting
  54 + for its termination.
  55 + '''
  56 + logger.info('run_script "%s"', script)
  57 +
  58 + output = None
52 script = path.expanduser(script) 59 script = path.expanduser(script)
  60 + cmd = [script] + [str(a) for a in args]
  61 +
  62 + # --- run process
53 try: 63 try:
54 - cmd = [script] + [str(a) for a in args]  
55 - p = subprocess.run(cmd,  
56 - input=stdin,  
57 - stdout=subprocess.PIPE,  
58 - stderr=subprocess.STDOUT,  
59 - universal_newlines=True,  
60 - timeout=timeout,  
61 - )  
62 - except FileNotFoundError:  
63 - logger.error(f'Can not execute script "{script}": not found.')  
64 - except PermissionError:  
65 - logger.error(f'Can not execute script "{script}": wrong permissions.') 64 + proc = subprocess.run(cmd,
  65 + input=stdin,
  66 + stdout=subprocess.PIPE,
  67 + stderr=subprocess.STDOUT,
  68 + universal_newlines=True,
  69 + timeout=timeout,
  70 + check=False,
  71 + )
66 except OSError: 72 except OSError:
67 - logger.error(f'Can not execute script "{script}": unknown reason.') 73 + logger.error('Can not execute script "%s".', script)
  74 + return output
68 except subprocess.TimeoutExpired: 75 except subprocess.TimeoutExpired:
69 - logger.error(f'Timeout {timeout}s exceeded while running "{script}".') 76 + logger.error('Timeout %ds exceeded running "%s".', timeout, script)
  77 + return output
70 except Exception: 78 except Exception:
71 - logger.error(f'An Exception ocurred running {script}.')  
72 - else:  
73 - if p.returncode != 0:  
74 - logger.error(f'Return code {p.returncode} running "{script}".')  
75 - else:  
76 - try:  
77 - output = yaml.safe_load(p.stdout)  
78 - except Exception:  
79 - logger.error(f'Error parsing yaml output of "{script}"')  
80 - else:  
81 - return output 79 + logger.error('An Exception ocurred running "%s".', script)
  80 + return output
  81 +
  82 + # --- check return code
  83 + if proc.returncode != 0:
  84 + logger.error('Return code %d running "%s".', proc.returncode, script)
  85 + return output
  86 +
  87 + # --- parse yaml
  88 + try:
  89 + output = yaml.safe_load(proc.stdout)
  90 + except yaml.YAMLError:
  91 + logger.error('Error parsing yaml output of "%s".', script)
  92 +
  93 + return output
82 94
83 95
84 -# ----------------------------------------------------------------------------  
85 -# Same as above, but asynchronous  
86 # ---------------------------------------------------------------------------- 96 # ----------------------------------------------------------------------------
87 async def run_script_async(script: str, 97 async def run_script_async(script: str,
88 - args: List[str] = [], 98 + args: List[str],
89 stdin: str = '', 99 stdin: str = '',
90 - timeout: int = 2) -> Any: 100 + timeout: int = 3) -> Any:
  101 + '''Same as above, but asynchronous'''
91 102
92 script = path.expanduser(script) 103 script = path.expanduser(script)
93 args = [str(a) for a in args] 104 args = [str(a) for a in args]
  105 + output = None
94 106
95 - p = await asyncio.create_subprocess_exec(  
96 - script, *args,  
97 - stdin=asyncio.subprocess.PIPE,  
98 - stdout=asyncio.subprocess.PIPE,  
99 - stderr=asyncio.subprocess.DEVNULL,  
100 - )  
101 - 107 + # --- start process
102 try: 108 try:
103 - stdout, stderr = await asyncio.wait_for(  
104 - p.communicate(input=stdin.encode('utf-8')),  
105 - timeout=timeout 109 + proc = await asyncio.create_subprocess_exec(
  110 + script, *args,
  111 + stdin=asyncio.subprocess.PIPE,
  112 + stdout=asyncio.subprocess.PIPE,
  113 + stderr=asyncio.subprocess.DEVNULL,
106 ) 114 )
  115 + except OSError:
  116 + logger.error('Can not execute script "%s".', script)
  117 + return output
  118 +
  119 + # --- send input and wait for termination
  120 + try:
  121 + stdout, _ = await asyncio.wait_for(
  122 + proc.communicate(input=stdin.encode('utf-8')),
  123 + timeout=timeout)
107 except asyncio.TimeoutError: 124 except asyncio.TimeoutError:
108 - logger.warning(f'Timeout {timeout}s running script "{script}".')  
109 - return 125 + logger.warning('Timeout %ds running script "%s".', timeout, script)
  126 + return output
110 127
111 - if p.returncode != 0:  
112 - logger.error(f'Return code {p.returncode} running "{script}".')  
113 - else:  
114 - try:  
115 - output = yaml.safe_load(stdout.decode('utf-8', 'ignore'))  
116 - except Exception:  
117 - logger.error(f'Error parsing yaml output of "{script}"')  
118 - else:  
119 - return output 128 + # --- check return code
  129 + if proc.returncode != 0:
  130 + logger.error('Return code %d running "%s".', proc.returncode, script)
  131 + return output
  132 +
  133 + # --- parse yaml
  134 + try:
  135 + output = yaml.safe_load(stdout.decode('utf-8', 'ignore'))
  136 + except yaml.YAMLError:
  137 + logger.error('Error parsing yaml output of "%s"', script)
  138 +
  139 + return output