Commit 7e33bb8de4d94de9e28621d383dc5a66b5001e66
1 parent
1db56dc6
Exists in
master
and in
1 other branch
- new generator question in demo.yaml
- fix grade format in logs - fix many pyyaml warnings - rewrite run_script and run_script_async
Showing
11 changed files
with
441 additions
and
323 deletions
Show diff stats
demo/demo.yaml
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 |