Commit 7ce13d3942fec48f3be503b1feef077a4a024027
1 parent
4feda8e2
Exists in
master
and in
1 other branch
In the process of converting to sqlalchemy 1.4
Functional, but notifications (unfocus, password defined, etc) are not yet implemented.
Showing
15 changed files
with
575 additions
and
732 deletions
Show diff stats
... | ... | @@ -0,0 +1,77 @@ |
1 | +#!/usr/bin/env python3 | |
2 | + | |
3 | +''' | |
4 | +Example of a question generator. | |
5 | +Arguments are read from stdin. | |
6 | +''' | |
7 | + | |
8 | +from random import randint | |
9 | +import sys | |
10 | + | |
11 | +# read two arguments from the field `args` specified in the question yaml file | |
12 | +a, b = (int(n) for n in sys.argv[1:]) | |
13 | + | |
14 | +x = randint(a, b) | |
15 | +y = randint(a, b) | |
16 | +r = x + y | |
17 | + | |
18 | +print(f"""--- | |
19 | +type: text | |
20 | +title: Geradores de perguntas | |
21 | +text: | | |
22 | + | |
23 | + As perguntas podem ser estáticas (como as que vimos até aqui), ou serem | |
24 | + geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o | |
25 | + programa deve escrever texto no `stdout` em formato `yaml` tal como os | |
26 | + exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode | |
27 | + também receber argumentos de linha de comando para parametrizar a pergunta. | |
28 | + Aqui está um exemplo de uma pergunta gerada por um script python: | |
29 | + | |
30 | + ```python | |
31 | + #!/usr/bin/env python3 | |
32 | + | |
33 | + from random import randint | |
34 | + import sys | |
35 | + | |
36 | + a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando | |
37 | + | |
38 | + x = randint(a, b) # número inteiro no intervalo a..b | |
39 | + y = randint(a, b) # número inteiro no intervalo a..b | |
40 | + r = x + y # calcula resultado correcto | |
41 | + | |
42 | + print(f'''--- | |
43 | + type: text | |
44 | + title: Contas de somar | |
45 | + text: | | |
46 | + Calcule o resultado de ${{x}} + {{y}}$. | |
47 | + correct: '{{r}}' | |
48 | + solution: | | |
49 | + A solução é {{r}}.''') | |
50 | + ``` | |
51 | + | |
52 | + Este script deve ter permissões para poder ser executado no terminal. | |
53 | + Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que | |
54 | + o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar | |
55 | + que este script deve ser usado para gerar uma pergunta. | |
56 | + | |
57 | + Uma pergunta gerada por um programa externo é declarada com | |
58 | + | |
59 | + ```yaml | |
60 | + - type: generator | |
61 | + ref: gen-somar | |
62 | + script: gen-somar.py | |
63 | + # argumentos opcionais | |
64 | + args: [1, 100] | |
65 | + ``` | |
66 | + | |
67 | + O programa pode receber uma lista de argumentos de linha de comando | |
68 | + declarados em `args`. | |
69 | + | |
70 | + --- | |
71 | + | |
72 | + Calcule o resultado de ${x} + {y}$. | |
73 | + | |
74 | + Os números foram gerados aleatoriamente no intervalo de {a} a {b}. | |
75 | +correct: '{r}' | |
76 | +solution: | | |
77 | + A solução é {r}.""") | ... | ... |
demo/questions/generators/generate-question.py
... | ... | @@ -1,77 +0,0 @@ |
1 | -#!/usr/bin/env python3 | |
2 | - | |
3 | -''' | |
4 | -Example of a question generator. | |
5 | -Arguments are read from stdin. | |
6 | -''' | |
7 | - | |
8 | -from random import randint | |
9 | -import sys | |
10 | - | |
11 | -# read two arguments from the field `args` specified in the question yaml file | |
12 | -a, b = (int(n) for n in sys.argv[1:]) | |
13 | - | |
14 | -x = randint(a, b) | |
15 | -y = randint(a, b) | |
16 | -r = x + y | |
17 | - | |
18 | -print(f"""--- | |
19 | -type: text | |
20 | -title: Geradores de perguntas | |
21 | -text: | | |
22 | - | |
23 | - As perguntas podem ser estáticas (como as que vimos até aqui), ou serem | |
24 | - geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o | |
25 | - programa deve escrever texto no `stdout` em formato `yaml` tal como os | |
26 | - exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode | |
27 | - também receber argumentos de linha de comando para parametrizar a pergunta. | |
28 | - Aqui está um exemplo de uma pergunta gerada por um script python: | |
29 | - | |
30 | - ```python | |
31 | - #!/usr/bin/env python3 | |
32 | - | |
33 | - from random import randint | |
34 | - import sys | |
35 | - | |
36 | - a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando | |
37 | - | |
38 | - x = randint(a, b) # número inteiro no intervalo a..b | |
39 | - y = randint(a, b) # número inteiro no intervalo a..b | |
40 | - r = x + y # calcula resultado correcto | |
41 | - | |
42 | - print(f'''--- | |
43 | - type: text | |
44 | - title: Contas de somar | |
45 | - text: | | |
46 | - Calcule o resultado de ${{x}} + {{y}}$. | |
47 | - correct: '{{r}}' | |
48 | - solution: | | |
49 | - A solução é {{r}}.''') | |
50 | - ``` | |
51 | - | |
52 | - Este script deve ter permissões para poder ser executado no terminal. | |
53 | - Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que | |
54 | - o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar | |
55 | - que este script deve ser usado para gerar uma pergunta. | |
56 | - | |
57 | - Uma pergunta gerada por um programa externo é declarada com | |
58 | - | |
59 | - ```yaml | |
60 | - - type: generator | |
61 | - ref: gen-somar | |
62 | - script: gen-somar.py | |
63 | - # argumentos opcionais | |
64 | - args: [1, 100] | |
65 | - ``` | |
66 | - | |
67 | - O programa pode receber uma lista de argumentos de linha de comando | |
68 | - declarados em `args`. | |
69 | - | |
70 | - --- | |
71 | - | |
72 | - Calcule o resultado de ${x} + {y}$. | |
73 | - | |
74 | - Os números foram gerados aleatoriamente no intervalo de {a} a {b}. | |
75 | -correct: '{r}' | |
76 | -solution: | | |
77 | - A solução é {r}.""") |
demo/questions/questions-tutorial.yaml
... | ... | @@ -586,7 +586,7 @@ |
586 | 586 | # ---------------------------------------------------------------------------- |
587 | 587 | - type: generator |
588 | 588 | ref: tut-generator |
589 | - script: generators/generate-question.py | |
589 | + script: generate-question.py | |
590 | 590 | args: [1, 100] |
591 | 591 | |
592 | 592 | # ---------------------------------------------------------------------------- | ... | ... |
perguntations/app.py
... | ... | @@ -8,49 +8,41 @@ Description: Main application logic. |
8 | 8 | import asyncio |
9 | 9 | import csv |
10 | 10 | import io |
11 | -import json | |
12 | 11 | import logging |
13 | -from os import path | |
12 | +import os | |
13 | +from typing import Optional | |
14 | 14 | |
15 | 15 | # installed packages |
16 | 16 | import bcrypt |
17 | -from sqlalchemy import create_engine, select, func | |
17 | +from sqlalchemy import create_engine, select | |
18 | +from sqlalchemy.exc import OperationalError, NoResultFound, IntegrityError | |
18 | 19 | from sqlalchemy.orm import Session |
19 | -# from sqlalchemy.exc import NoResultFound | |
20 | +import yaml | |
20 | 21 | |
21 | 22 | # this project |
22 | 23 | from perguntations.models import Student, Test, Question |
23 | -from perguntations.questions import question_from | |
24 | 24 | from perguntations.tools import load_yaml |
25 | 25 | from perguntations.testfactory import TestFactory, TestFactoryException |
26 | -import perguntations.test | |
27 | 26 | |
28 | 27 | # setup logger for this module |
29 | 28 | logger = logging.getLogger(__name__) |
30 | 29 | |
31 | 30 | |
32 | -# ============================================================================ | |
33 | -class AppException(Exception): | |
34 | - '''Exception raised in this module''' | |
31 | +async def check_password(password: str, hashed: bytes) -> bool: | |
32 | + '''check password in executor''' | |
33 | + loop = asyncio.get_running_loop() | |
34 | + return await loop.run_in_executor(None, bcrypt.checkpw, | |
35 | + password.encode('utf-8'), hashed) | |
35 | 36 | |
37 | +async def hash_password(password: str) -> bytes: | |
38 | + '''get hash for password''' | |
39 | + loop = asyncio.get_running_loop() | |
40 | + return await loop.run_in_executor(None, bcrypt.hashpw, | |
41 | + password.encode('utf-8'), bcrypt.gensalt()) | |
36 | 42 | |
37 | 43 | # ============================================================================ |
38 | -# helper functions | |
39 | -# ============================================================================ | |
40 | -# async def check_password(try_pw, hashed_pw): | |
41 | -# '''check password in executor''' | |
42 | -# try_pw = try_pw.encode('utf-8') | |
43 | -# loop = asyncio.get_running_loop() | |
44 | -# hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, hashed_pw) | |
45 | -# return hashed_pw == hashed | |
46 | - | |
47 | - | |
48 | -# async def hash_password(password): | |
49 | -# '''hash password in executor''' | |
50 | -# loop = asyncio.get_running_loop() | |
51 | -# return await loop.run_in_executor(None, bcrypt.hashpw, | |
52 | -# password.encode('utf-8'), | |
53 | -# bcrypt.gensalt()) | |
44 | +class AppException(Exception): | |
45 | + '''Exception raised in this module''' | |
54 | 46 | |
55 | 47 | |
56 | 48 | # ============================================================================ |
... | ... | @@ -58,279 +50,154 @@ class AppException(Exception): |
58 | 50 | # ============================================================================ |
59 | 51 | class App(): |
60 | 52 | ''' |
61 | - This is the main application | |
62 | - state: | |
63 | - self.Session | |
64 | - self.online - {uid: | |
65 | - {'student':{...}, 'test': {...}}, | |
66 | - ... | |
67 | - } | |
68 | - self.allowd - {'123', '124', ...} | |
69 | - self.testfactory - TestFactory | |
53 | + Main application | |
70 | 54 | ''' |
71 | 55 | |
72 | - # # ------------------------------------------------------------------------ | |
73 | - # @contextmanager | |
74 | - # def _db_session(self): | |
75 | - # ''' | |
76 | - # helper to manage db sessions using the `with` statement, for example: | |
77 | - # with self._db_session() as s: s.query(...) | |
78 | - # ''' | |
79 | - # session = self.Session() | |
80 | - # try: | |
81 | - # yield session | |
82 | - # session.commit() | |
83 | - # except exc.SQLAlchemyError: | |
84 | - # logger.error('DB rollback!!!') | |
85 | - # session.rollback() | |
86 | - # raise | |
87 | - # finally: | |
88 | - # session.close() | |
89 | - | |
90 | 56 | # ------------------------------------------------------------------------ |
91 | - def __init__(self, conf): | |
92 | - self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...} | |
93 | - self.allowed = set() # '0' is hardcoded to allowed elsewhere | |
94 | - self.unfocus = set() # set of students that have no browser focus | |
95 | - self.area = dict() # {uid: percent_area} | |
96 | - self.pregenerated_tests = [] # list of tests to give to students | |
97 | - | |
98 | - self._make_test_factory(conf) | |
99 | - self._db_setup() | |
100 | - | |
101 | - # command line option --allow-all | |
102 | - if conf['allow_all']: | |
57 | + def __init__(self, config): | |
58 | + self._make_test_factory(config['testfile']) | |
59 | + self._db_setup() # setup engine and load all students | |
60 | + | |
61 | + # command line options: --allow-all, --allow-list filename | |
62 | + if config['allow_all']: | |
103 | 63 | self.allow_all_students() |
104 | - elif conf['allow_list'] is not None: | |
105 | - self.allow_list(conf['allow_list']) | |
64 | + elif config['allow_list'] is not None: | |
65 | + self.allow_from_list(config['allow_list']) | |
106 | 66 | else: |
107 | - logger.info('Students not yet allowed to login.') | |
108 | - | |
109 | - # pre-generate tests for allowed students | |
110 | - if self.allowed: | |
111 | - logger.info('Generating %d tests. May take awhile...', | |
112 | - len(self.allowed)) | |
113 | - self._pregenerate_tests(len(self.allowed)) | |
67 | + logger.info('Students login not yet allowed') | |
114 | 68 | |
115 | - if conf['correct']: | |
116 | - self._correct_tests() | |
69 | + # if config['correct']: | |
70 | + # self._correct_tests() | |
117 | 71 | |
118 | 72 | # ------------------------------------------------------------------------ |
119 | 73 | def _db_setup(self) -> None: |
120 | - logger.info('Setup database') | |
74 | + logger.debug('Checking database...') | |
121 | 75 | |
122 | 76 | # connect to database and check registered students |
123 | - dbfile = path.expanduser(self.testfactory['database']) | |
124 | - if not path.exists(dbfile): | |
125 | - raise AppException('Database does not exist. Use "initdb" to create.') | |
77 | + dbfile = os.path.expanduser(self._testfactory['database']) | |
78 | + if not os.path.exists(dbfile): | |
79 | + raise AppException('No database. Use "initdb" to create.') | |
126 | 80 | self._engine = create_engine(f'sqlite:///{dbfile}', future=True) |
127 | - | |
128 | 81 | try: |
129 | 82 | with Session(self._engine, future=True) as session: |
130 | - num = session.execute( | |
131 | - select(func.count(Student.id)).where(Student.id != '0') | |
132 | - ).scalar() | |
133 | - except Exception as exc: | |
134 | - raise AppException(f'Database unusable {dbfile}.') from exc | |
135 | - | |
136 | - logger.info('Database "%s" has %s students.', dbfile, num) | |
137 | - | |
138 | -# # ------------------------------------------------------------------------ | |
139 | -# # FIXME not working | |
140 | -# def _correct_tests(self): | |
141 | -# with Session(self._engine, future=True) as session: | |
142 | -# # Find which tests have to be corrected | |
143 | -# dbtests = session.execute( | |
144 | -# select(Test). | |
145 | -# where(Test.ref == self.testfactory['ref']). | |
146 | -# where(Test.state == "SUBMITTED") | |
147 | -# ).all() | |
148 | -# # dbtests = session.query(Test)\ | |
149 | -# # .filter(Test.ref == self.testfactory['ref'])\ | |
150 | -# # .filter(Test.state == "SUBMITTED")\ | |
151 | -# # .all() | |
83 | + query = select(Student.id, Student.name)\ | |
84 | + .where(Student.id != '0') | |
85 | + dbstudents = session.execute(query).all() | |
86 | + session.execute(select(Student).where(Student.id == '0')).one() | |
87 | + except NoResultFound: | |
88 | + msg = 'Database has no administrator (user "0")' | |
89 | + logger.error(msg) | |
90 | + raise AppException(msg) from None | |
91 | + except OperationalError: | |
92 | + msg = f'Database "{dbfile}" unusable.' | |
93 | + logger.error(msg) | |
94 | + raise AppException(msg) from None | |
152 | 95 | |
153 | -# logger.info('Correcting %d tests...', len(dbtests)) | |
154 | -# for dbtest in dbtests: | |
155 | -# try: | |
156 | -# with open(dbtest.filename) as file: | |
157 | -# testdict = json.load(file) | |
158 | -# except FileNotFoundError: | |
159 | -# logger.error('File not found: %s', dbtest.filename) | |
160 | -# continue | |
96 | + logger.info('Database "%s" has %d students.', dbfile, len(dbstudents)) | |
161 | 97 | |
162 | -# # creates a class Test with the methods to correct it | |
163 | -# # the questions are still dictionaries, so we have to call | |
164 | -# # question_from() to produce Question() instances that can be | |
165 | -# # corrected. Finally the test can be corrected. | |
166 | -# test = perguntations.test.Test(testdict) | |
167 | -# test['questions'] = [question_from(q) for q in test['questions']] | |
168 | -# test.correct() | |
169 | -# logger.info('Student %s: grade = %f', test['student']['number'], test['grade']) | |
170 | - | |
171 | -# # save JSON file (overwriting the old one) | |
172 | -# uid = test['student']['number'] | |
173 | -# ref = test['ref'] | |
174 | -# finish_time = test['finish_time'] | |
175 | -# answers_dir = test['answers_dir'] | |
176 | -# fname = f'{uid}--{ref}--{finish_time}.json' | |
177 | -# fpath = path.join(answers_dir, fname) | |
178 | -# test.save_json(fpath) | |
179 | -# logger.info('%s saved JSON file.', uid) | |
98 | + self._students = {uid: {'name': name, 'state': 'offline', 'test': None} | |
99 | + for uid, name in dbstudents} | |
180 | 100 | |
181 | -# # update database | |
182 | -# dbtest.grade = test['grade'] | |
183 | -# dbtest.state = test['state'] | |
184 | -# dbtest.questions = [ | |
185 | -# Question( | |
186 | -# number=n, | |
187 | -# ref=q['ref'], | |
188 | -# grade=q['grade'], | |
189 | -# comment=q.get('comment', ''), | |
190 | -# starttime=str(test['start_time']), | |
191 | -# finishtime=str(test['finish_time']), | |
192 | -# test_id=test['ref'] | |
193 | -# ) | |
194 | -# for n, q in enumerate(test['questions']) | |
195 | -# ] | |
196 | -# logger.info('%s database updated.', uid) | |
101 | + # self._students = {} | |
102 | + # for uid, name in dbstudents: | |
103 | + # self._students[uid] = { | |
104 | + # 'name': name, | |
105 | + # 'state': 'offline', # offline, allowed, waiting, online | |
106 | + # 'test': None | |
107 | + # } | |
197 | 108 | |
198 | 109 | # ------------------------------------------------------------------------ |
199 | - async def login(self, uid, password, headers=None): | |
200 | - '''login authentication''' | |
201 | - if uid != '0' and uid not in self.allowed: # not allowed | |
202 | - logger.warning('"%s" unauthorized.', uid) | |
203 | - return 'unauthorized' | |
204 | - | |
205 | - with Session(self._engine, future=True) as session: | |
206 | - name, hashed = session.execute( | |
207 | - select(Student.name, Student.password). | |
208 | - where(Student.id == uid) | |
209 | - ).one() | |
210 | - | |
211 | - if hashed == '': # update password on first login | |
212 | - logger.info('First login "%s"', name) | |
213 | - await self.update_password(uid, password) | |
214 | - ok = True | |
215 | - else: # check password | |
216 | - loop = asyncio.get_running_loop() | |
217 | - ok = await loop.run_in_executor(None, | |
218 | - bcrypt.checkpw, | |
219 | - password.encode('utf-8'), | |
220 | - hashed) | |
221 | - | |
222 | - if not ok: | |
223 | - logger.info('"%s" wrong password.', uid) | |
110 | + async def login(self, uid: str, password: str, headers: dict) -> Optional[str]: | |
111 | + ''' | |
112 | + Login authentication | |
113 | + If successful returns None, else returns an error message | |
114 | + ''' | |
115 | + try: | |
116 | + with Session(self._engine, future=True) as session: | |
117 | + query = select(Student.password).where(Student.id == uid) | |
118 | + hashed = session.execute(query).scalar_one() | |
119 | + except NoResultFound: | |
120 | + logger.warning('"%s" does not exist', uid) | |
121 | + return 'nonexistent' | |
122 | + | |
123 | + if uid != '0' and self._students[uid]['state'] != 'allowed': | |
124 | + logger.warning('"%s" login not allowed', uid) | |
125 | + return 'not allowed' | |
126 | + | |
127 | + if hashed == '': # set password on first login | |
128 | + await self.set_password(uid, password) | |
129 | + elif not await check_password(password, hashed): | |
130 | + logger.info('"%s" wrong password', uid) | |
224 | 131 | return 'wrong_password' |
225 | 132 | |
226 | 133 | # success |
227 | - self.allowed.discard(uid) # remove from set of allowed students | |
134 | + if uid == '0': | |
135 | + logger.info('Admin login from %s', headers['remote_ip']) | |
136 | + return | |
228 | 137 | |
229 | - if uid in self.online: | |
230 | - logger.warning('"%s" login again from %s (reusing state).', | |
231 | - uid, headers['remote_ip']) | |
232 | - # FIXME invalidate previous login | |
233 | - else: | |
234 | - # first login | |
235 | - self.online[uid] = {'student': { | |
236 | - 'name': name, | |
237 | - 'number': uid, | |
238 | - 'headers': headers}} | |
239 | - logger.info('"%s" login from %s.', uid, headers['remote_ip']) | |
138 | + # FIXME this should probably be done elsewhere | |
139 | + test = await self._testfactory.generate() | |
140 | + test.start(uid) | |
141 | + self._students[uid]['test'] = test | |
142 | + | |
143 | + self._students[uid]['state'] = 'waiting' | |
144 | + self._students[uid]['headers'] = headers | |
145 | + logger.info('"%s" login from %s.', uid, headers['remote_ip']) | |
146 | + | |
147 | + # ------------------------------------------------------------------------ | |
148 | + async def set_password(self, uid: str, password: str) -> None: | |
149 | + '''change password on the database''' | |
150 | + with Session(self._engine, future=True) as session: | |
151 | + query = select(Student).where(Student.id == uid) | |
152 | + student = session.execute(query).scalar_one() | |
153 | + student.password = await hash_password(password) if password else '' | |
154 | + session.commit() | |
155 | + logger.info('"%s" password updated', uid) | |
240 | 156 | |
241 | 157 | # ------------------------------------------------------------------------ |
242 | - def logout(self, uid): | |
158 | + def logout(self, uid: str) -> None: | |
243 | 159 | '''student logout''' |
244 | - self.online.pop(uid, None) # remove from dict if exists | |
160 | + if uid in self._students: | |
161 | + self._students[uid]['test'] = None | |
162 | + self._students[uid]['state'] = 'offline' | |
245 | 163 | logger.info('"%s" logged out.', uid) |
246 | 164 | |
247 | 165 | # ------------------------------------------------------------------------ |
248 | - def _make_test_factory(self, conf): | |
166 | + def _make_test_factory(self, filename: str) -> None: | |
249 | 167 | ''' |
250 | 168 | Setup a factory for the test |
251 | 169 | ''' |
252 | 170 | |
253 | 171 | # load configuration from yaml file |
254 | - logger.info('Loading test configuration "%s".', conf["testfile"]) | |
255 | 172 | try: |
256 | - testconf = load_yaml(conf['testfile']) | |
257 | - except Exception as exc: | |
258 | - msg = 'Error loading test configuration YAML.' | |
259 | - logger.critical(msg) | |
173 | + testconf = load_yaml(filename) | |
174 | + testconf['testfile'] = filename | |
175 | + except (IOError, yaml.YAMLError) as exc: | |
176 | + msg = f'Cannot read test configuration "{filename}"' | |
177 | + logger.error(msg) | |
260 | 178 | raise AppException(msg) from exc |
261 | 179 | |
262 | - # command line options override configuration | |
263 | - testconf.update(conf) | |
264 | - | |
265 | - # start test factory | |
180 | + # make test factory | |
266 | 181 | logger.info('Running test factory...') |
267 | 182 | try: |
268 | - self.testfactory = TestFactory(testconf) | |
183 | + self._testfactory = TestFactory(testconf) | |
269 | 184 | except TestFactoryException as exc: |
270 | - logger.critical(exc) | |
185 | + logger.error(exc) | |
271 | 186 | raise AppException('Failed to create test factory!') from exc |
272 | 187 | |
273 | 188 | # ------------------------------------------------------------------------ |
274 | - def _pregenerate_tests(self, num): # TODO needs improvement | |
275 | - event_loop = asyncio.get_event_loop() | |
276 | - self.pregenerated_tests += [ | |
277 | - event_loop.run_until_complete(self.testfactory.generate()) | |
278 | - for _ in range(num)] | |
279 | - | |
280 | - # ------------------------------------------------------------------------ | |
281 | - async def get_test_or_generate(self, uid): | |
282 | - '''get current test or generate a new one''' | |
283 | - try: | |
284 | - student = self.online[uid] | |
285 | - except KeyError as exc: | |
286 | - msg = f'"{uid}" is not online. get_test_or_generate() FAILED' | |
287 | - logger.error(msg) | |
288 | - raise AppException(msg) from exc | |
289 | - | |
290 | - # get current test. if test does not exist then generate a new one | |
291 | - if not 'test' in student: | |
292 | - await self._new_test(uid) | |
293 | - | |
294 | - return student['test'] | |
295 | - | |
296 | - def get_test(self, uid): | |
297 | - '''get test from online student or raise exception''' | |
298 | - return self.online[uid]['test'] | |
299 | - | |
300 | - # ------------------------------------------------------------------------ | |
301 | - async def _new_test(self, uid): | |
302 | - ''' | |
303 | - assign a test to a given student. if there are pregenerated tests then | |
304 | - use one of them, otherwise generate one. | |
305 | - the student must be online | |
306 | - ''' | |
307 | - student = self.online[uid]['student'] # {'name': ?, 'number': ?} | |
308 | - | |
309 | - try: | |
310 | - test = self.pregenerated_tests.pop() | |
311 | - except IndexError: | |
312 | - logger.info('"%s" generating new test...', uid) | |
313 | - test = await self.testfactory.generate() | |
314 | - logger.info('"%s" test is ready.', uid) | |
315 | - else: | |
316 | - logger.info('"%s" using a pregenerated test.', uid) | |
317 | - | |
318 | - test.start(student) # student signs the test | |
319 | - self.online[uid]['test'] = test | |
320 | - | |
321 | - # ------------------------------------------------------------------------ | |
322 | - async def submit_test(self, uid, ans): | |
189 | + async def submit_test(self, uid, ans) -> None: | |
323 | 190 | ''' |
324 | 191 | Handles test submission and correction. |
325 | 192 | |
326 | 193 | ans is a dictionary {question_index: answer, ...} with the answers for |
327 | 194 | the complete test. For example: {0:'hello', 1:[1,2]} |
328 | 195 | ''' |
329 | - test = self.online[uid]['test'] | |
196 | + logger.info('"%s" submitted %d answers.', uid, len(ans)) | |
330 | 197 | |
331 | 198 | # --- submit answers and correct test |
199 | + test = self._students[uid]['test'] | |
332 | 200 | test.submit(ans) |
333 | - logger.info('"%s" submitted %d answers.', uid, len(ans)) | |
334 | 201 | |
335 | 202 | if test['autocorrect']: |
336 | 203 | await test.correct_async() |
... | ... | @@ -338,7 +205,7 @@ class App(): |
338 | 205 | |
339 | 206 | # --- save test in JSON format |
340 | 207 | fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json' |
341 | - fpath = path.join(test['answers_dir'], fname) | |
208 | + fpath = os.path.join(test['answers_dir'], fname) | |
342 | 209 | test.save_json(fpath) |
343 | 210 | logger.info('"%s" saved JSON.', uid) |
344 | 211 | |
... | ... | @@ -374,9 +241,66 @@ class App(): |
374 | 241 | session.commit() |
375 | 242 | logger.info('"%s" database updated.', uid) |
376 | 243 | |
377 | - # ------------------------------------------------------------------------ | |
378 | - def get_student_grade(self, uid): | |
379 | - return self.online[uid]['test'].get('grade', None) | |
244 | +# # ------------------------------------------------------------------------ | |
245 | + # FIXME not working | |
246 | +# def _correct_tests(self): | |
247 | +# with Session(self._engine, future=True) as session: | |
248 | +# # Find which tests have to be corrected | |
249 | +# dbtests = session.execute( | |
250 | +# select(Test). | |
251 | +# where(Test.ref == self.testfactory['ref']). | |
252 | +# where(Test.state == "SUBMITTED") | |
253 | +# ).all() | |
254 | +# # dbtests = session.query(Test)\ | |
255 | +# # .filter(Test.ref == self.testfactory['ref'])\ | |
256 | +# # .filter(Test.state == "SUBMITTED")\ | |
257 | +# # .all() | |
258 | + | |
259 | +# logger.info('Correcting %d tests...', len(dbtests)) | |
260 | +# for dbtest in dbtests: | |
261 | +# try: | |
262 | +# with open(dbtest.filename) as file: | |
263 | +# testdict = json.load(file) | |
264 | +# except FileNotFoundError: | |
265 | +# logger.error('File not found: %s', dbtest.filename) | |
266 | +# continue | |
267 | + | |
268 | +# # creates a class Test with the methods to correct it | |
269 | +# # the questions are still dictionaries, so we have to call | |
270 | +# # question_from() to produce Question() instances that can be | |
271 | +# # corrected. Finally the test can be corrected. | |
272 | +# test = perguntations.test.Test(testdict) | |
273 | +# test['questions'] = [question_from(q) for q in test['questions']] | |
274 | +# test.correct() | |
275 | +# logger.info('Student %s: grade = %f', test['student']['number'], test['grade']) | |
276 | + | |
277 | +# # save JSON file (overwriting the old one) | |
278 | +# uid = test['student']['number'] | |
279 | +# ref = test['ref'] | |
280 | +# finish_time = test['finish_time'] | |
281 | +# answers_dir = test['answers_dir'] | |
282 | +# fname = f'{uid}--{ref}--{finish_time}.json' | |
283 | +# fpath = os.path.join(answers_dir, fname) | |
284 | +# test.save_json(fpath) | |
285 | +# logger.info('%s saved JSON file.', uid) | |
286 | + | |
287 | +# # update database | |
288 | +# dbtest.grade = test['grade'] | |
289 | +# dbtest.state = test['state'] | |
290 | +# dbtest.questions = [ | |
291 | +# Question( | |
292 | +# number=n, | |
293 | +# ref=q['ref'], | |
294 | +# grade=q['grade'], | |
295 | +# comment=q.get('comment', ''), | |
296 | +# starttime=str(test['start_time']), | |
297 | +# finishtime=str(test['finish_time']), | |
298 | +# test_id=test['ref'] | |
299 | +# ) | |
300 | +# for n, q in enumerate(test['questions']) | |
301 | +# ] | |
302 | +# logger.info('%s database updated.', uid) | |
303 | + | |
380 | 304 | |
381 | 305 | # ------------------------------------------------------------------------ |
382 | 306 | # def giveup_test(self, uid): |
... | ... | @@ -388,7 +312,7 @@ class App(): |
388 | 312 | # fields = (test['student']['number'], test['ref'], |
389 | 313 | # str(test['finish_time'])) |
390 | 314 | # fname = '--'.join(fields) + '.json' |
391 | - # fpath = path.join(test['answers_dir'], fname) | |
315 | + # fpath = os.path.join(test['answers_dir'], fname) | |
392 | 316 | # test.save_json(fpath) |
393 | 317 | |
394 | 318 | # # insert test into database |
... | ... | @@ -409,137 +333,143 @@ class App(): |
409 | 333 | # ------------------------------------------------------------------------ |
410 | 334 | def event_test(self, uid, cmd, value): |
411 | 335 | '''handles browser events the occur during the test''' |
412 | - if cmd == 'focus': | |
413 | - if value: | |
414 | - self._focus_student(uid) | |
415 | - else: | |
416 | - self._unfocus_student(uid) | |
417 | - elif cmd == 'size': | |
418 | - self._set_screen_area(uid, value) | |
336 | + # if cmd == 'focus': | |
337 | + # if value: | |
338 | + # self._focus_student(uid) | |
339 | + # else: | |
340 | + # self._unfocus_student(uid) | |
341 | + # elif cmd == 'size': | |
342 | + # self._set_screen_area(uid, value) | |
343 | + | |
344 | + # ======================================================================== | |
345 | + # GETTERS | |
346 | + # ======================================================================== | |
347 | + def get_test(self, uid: str) -> Optional[dict]: | |
348 | + '''return student test''' | |
349 | + return self._students[uid]['test'] | |
419 | 350 | |
420 | 351 | # ------------------------------------------------------------------------ |
421 | - # --- GETTERS | |
422 | - # ------------------------------------------------------------------------ | |
352 | + def get_name(self, uid: str) -> str: | |
353 | + '''return name of student''' | |
354 | + return self._students[uid]['name'] | |
423 | 355 | |
424 | - # def get_student_name(self, uid): | |
425 | - # return self.online[uid]['student']['name'] | |
356 | + # ------------------------------------------------------------------------ | |
357 | + def get_test_config(self) -> dict: | |
358 | + '''return brief test configuration''' | |
359 | + return {'title': self._testfactory['title'], | |
360 | + 'ref': self._testfactory['ref'], | |
361 | + 'filename': self._testfactory['testfile'], | |
362 | + 'database': self._testfactory['database'], | |
363 | + 'answers_dir': self._testfactory['answers_dir'] | |
364 | + } | |
426 | 365 | |
427 | - def get_questions_csv(self): | |
428 | - '''generates a CSV with the grades of the test''' | |
429 | - test_ref = self.testfactory['ref'] | |
366 | + # ------------------------------------------------------------------------ | |
367 | + def get_test_csv(self): | |
368 | + '''generates a CSV with the grades of the test currently running''' | |
369 | + test_ref = self._testfactory['ref'] | |
430 | 370 | with Session(self._engine, future=True) as session: |
431 | - questions = session.execute( | |
432 | - select(Test.id, Test.student_id, Test.starttime, | |
433 | - Question.number, Question.grade). | |
434 | - join(Question). | |
435 | - where(Test.ref == test_ref) | |
436 | - ).all() | |
437 | - print(questions) | |
438 | - | |
439 | - | |
440 | - | |
441 | - # questions = sess.query(Test.id, Test.student_id, Test.starttime, | |
442 | - # Question.number, Question.grade)\ | |
443 | - # .join(Question)\ | |
444 | - # .filter(Test.ref == test_ref)\ | |
445 | - # .all() | |
446 | - | |
447 | - qnums = set() # keeps track of all the questions in the test | |
448 | - tests = {} # {test_id: {student_id, starttime, 0: grade, ...}} | |
449 | - for question in questions: | |
450 | - test_id, student_id, starttime, num, grade = question | |
451 | - default_test_id = {'Aluno': student_id, 'Início': starttime} | |
452 | - tests.setdefault(test_id, default_test_id)[num] = grade | |
453 | - qnums.add(num) | |
454 | - | |
371 | + query = select(Test.student_id, Test.grade, | |
372 | + Test.starttime, Test.finishtime)\ | |
373 | + .where(Test.ref == test_ref)\ | |
374 | + .order_by(Test.student_id) | |
375 | + tests = session.execute(query).all() | |
455 | 376 | if not tests: |
456 | 377 | logger.warning('Empty CSV: there are no tests!') |
457 | 378 | return test_ref, '' |
458 | 379 | |
459 | - cols = ['Aluno', 'Início'] + list(qnums) | |
460 | - | |
461 | 380 | csvstr = io.StringIO() |
462 | - writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, | |
463 | - delimiter=';', quoting=csv.QUOTE_ALL) | |
464 | - writer.writeheader() | |
465 | - writer.writerows(tests.values()) | |
381 | + writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) | |
382 | + writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) | |
383 | + writer.writerows(tests) | |
466 | 384 | return test_ref, csvstr.getvalue() |
467 | 385 | |
468 | - def get_test_csv(self): | |
469 | - '''generates a CSV with the grades of the test currently running''' | |
470 | - test_ref = self.testfactory['ref'] | |
386 | + # ------------------------------------------------------------------------ | |
387 | + def get_detailed_grades_csv(self): | |
388 | + '''generates a CSV with the grades of the test''' | |
389 | + test_ref = self._testfactory['ref'] | |
471 | 390 | with Session(self._engine, future=True) as session: |
472 | - tests = session.execute( | |
473 | - select(Test.student_id, Test.grade, Test.starttime, Test.finishtime). | |
474 | - where(Test.ref == test_ref). | |
475 | - order_by(Test.student_id) | |
476 | - ).all() | |
477 | - # with self._db_session() as sess: | |
478 | - # tests = sess.query(Test.student_id, | |
479 | - # Test.grade, | |
480 | - # Test.starttime, Test.finishtime)\ | |
481 | - # .filter(Test.ref == test_ref)\ | |
482 | - # .order_by(Test.student_id)\ | |
483 | - # .all() | |
484 | - | |
485 | - print(tests) | |
391 | + query = select(Test.id, Test.student_id, Test.starttime, | |
392 | + Question.number, Question.grade)\ | |
393 | + .join(Question)\ | |
394 | + .where(Test.ref == test_ref) | |
395 | + questions = session.execute(query).all() | |
396 | + | |
397 | + cols = ['Aluno', 'Início'] | |
398 | + tests = {} # {test_id: {student_id, starttime, 0: grade, ...}} | |
399 | + for test_id, student_id, starttime, num, grade in questions: | |
400 | + default_test_id = {'Aluno': student_id, 'Início': starttime} | |
401 | + tests.setdefault(test_id, default_test_id)[num] = grade | |
402 | + if num not in cols: | |
403 | + cols.append(num) | |
404 | + | |
486 | 405 | if not tests: |
487 | 406 | logger.warning('Empty CSV: there are no tests!') |
488 | 407 | return test_ref, '' |
489 | 408 | |
490 | 409 | csvstr = io.StringIO() |
491 | - writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) | |
492 | - writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) | |
493 | - writer.writerows(tests) | |
494 | - | |
410 | + writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, | |
411 | + delimiter=';', quoting=csv.QUOTE_ALL) | |
412 | + writer.writeheader() | |
413 | + writer.writerows(tests.values()) | |
495 | 414 | return test_ref, csvstr.getvalue() |
496 | 415 | |
497 | 416 | # ------------------------------------------------------------------------ |
498 | - def get_student_grades_from_all_tests(self, uid): | |
499 | - '''get grades of student from all tests''' | |
500 | - with self._db_session() as sess: | |
501 | - return sess.query(Test.title, Test.grade, Test.finishtime)\ | |
502 | - .filter_by(student_id=uid)\ | |
503 | - .order_by(Test.finishtime) | |
504 | - | |
505 | 417 | def get_json_filename_of_test(self, test_id): |
506 | 418 | '''get JSON filename from database given the test_id''' |
507 | - with self._db_session() as sess: | |
508 | - return sess.query(Test.filename)\ | |
509 | - .filter_by(id=test_id)\ | |
510 | - .scalar() | |
419 | + with Session(self._engine, future=True) as session: | |
420 | + query = select(Test.filename).where(Test.id == test_id) | |
421 | + return session.execute(query).scalar() | |
511 | 422 | |
512 | - def get_student_grades_from_test(self, uid, testid): | |
423 | + # ------------------------------------------------------------------------ | |
424 | + def get_grades(self, uid, ref): | |
513 | 425 | '''get grades of student for a given testid''' |
514 | - with self._db_session() as sess: | |
515 | - return sess.query(Test.grade, Test.finishtime, Test.id)\ | |
516 | - .filter_by(student_id=uid)\ | |
517 | - .filter_by(ref=testid)\ | |
518 | - .all() | |
426 | + with Session(self._engine, future=True) as session: | |
427 | + query = select(Test.grade, Test.finishtime, Test.id)\ | |
428 | + .where(Test.student_id == uid)\ | |
429 | + .where(Test.ref == ref) | |
430 | + grades = session.execute(query).all() | |
431 | + return [tuple(grade) for grade in grades] | |
519 | 432 | |
520 | - def get_students_state(self): | |
433 | + # ------------------------------------------------------------------------ | |
434 | + def get_students_state(self) -> list: | |
521 | 435 | '''get list of states of every student''' |
522 | - return [{ | |
523 | - 'uid': uid, | |
524 | - 'name': name, | |
525 | - 'allowed': uid in self.allowed, | |
526 | - 'online': uid in self.online, | |
527 | - 'start_time': self.online.get(uid, {}).get('test', {}) | |
528 | - .get('start_time', ''), | |
529 | - 'password_defined': pw != '', | |
530 | - 'unfocus': uid in self.unfocus, | |
531 | - 'area': self.area.get(uid, None), | |
532 | - 'grades': self.get_student_grades_from_test( | |
533 | - uid, self.testfactory['ref']) | |
534 | - } for uid, name, pw in self._get_all_students()] | |
436 | + return [{ 'uid': uid, | |
437 | + 'name': student['name'], | |
438 | + 'allowed': student['state'] == 'allowed', | |
439 | + 'online': student['state'] == 'online', | |
440 | + # 'start_time': student.get('test', {}).get('start_time', ''), | |
441 | + # 'password_defined': False, #pw != '', | |
442 | + # 'unfocus': False, | |
443 | + # 'area': '0.89', | |
444 | + 'grades': self.get_grades(uid, self._testfactory['ref']) } | |
445 | + for uid, student in self._students.items()] | |
446 | + | |
447 | + # ------------------------------------------------------------------------ | |
448 | + # def get_student_grades_from_all_tests(self, uid): | |
449 | + # '''get grades of student from all tests''' | |
450 | + # with self._db_session() as sess: | |
451 | + # return sess.query(Test.title, Test.grade, Test.finishtime)\ | |
452 | + # .filter_by(student_id=uid)\ | |
453 | + # .order_by(Test.finishtime) | |
535 | 454 | |
536 | 455 | # --- private methods ---------------------------------------------------- |
537 | - def _get_all_students(self): | |
538 | - '''get all students from database''' | |
539 | - with self._db_session() as sess: | |
540 | - return sess.query(Student.id, Student.name, Student.password)\ | |
541 | - .filter(Student.id != '0')\ | |
542 | - .order_by(Student.id) | |
456 | + # def _get_all_students(self): | |
457 | + # '''get all students from database''' | |
458 | + # with Session(self._engine, future=True) as session: | |
459 | + # query = select(Student.id, Student.name, Student.password)\ | |
460 | + # .where(Student.id != '0') | |
461 | + # students | |
462 | + # questions = session.execute( | |
463 | + # select(Test.id, Test.student_id, Test.starttime, | |
464 | + # Question.number, Question.grade). | |
465 | + # join(Question). | |
466 | + # where(Test.ref == test_ref) | |
467 | + # ).all() | |
468 | + | |
469 | + | |
470 | + # return session.query(Student.id, Student.name, Student.password)\ | |
471 | + # .filter(Student.id != '0')\ | |
472 | + # .order_by(Student.id) | |
543 | 473 | |
544 | 474 | # def get_allowed_students(self): |
545 | 475 | # # set of 'uid' allowed to login |
... | ... | @@ -550,105 +480,91 @@ class App(): |
550 | 480 | # t = self.get_student_test(uid) |
551 | 481 | # for q in t['questions']: |
552 | 482 | # if q['ref'] == ref and key in q['files']: |
553 | - # return path.abspath(path.join(q['path'], q['files'][key])) | |
554 | - | |
555 | - # ------------------------------------------------------------------------ | |
556 | - # --- SETTERS | |
557 | - # ------------------------------------------------------------------------ | |
483 | + # return os.path.abspath(os.path.join(q['path'], q['files'][key])) | |
558 | 484 | |
559 | - def allow_student(self, uid): | |
485 | + # ======================================================================== | |
486 | + # SETTERS | |
487 | + # ======================================================================== | |
488 | + def allow_student(self, uid: str) -> None: | |
560 | 489 | '''allow a single student to login''' |
561 | - self.allowed.add(uid) | |
490 | + self._students[uid]['state'] = 'allowed' | |
562 | 491 | logger.info('"%s" allowed to login.', uid) |
563 | 492 | |
564 | - def deny_student(self, uid): | |
493 | + # ------------------------------------------------------------------------ | |
494 | + def deny_student(self, uid: str) -> None: | |
565 | 495 | '''deny a single student to login''' |
566 | - self.allowed.discard(uid) | |
496 | + student = self._students[uid] | |
497 | + if student['state'] == 'allowed': | |
498 | + student['state'] = 'offline' | |
567 | 499 | logger.info('"%s" denied to login', uid) |
568 | 500 | |
569 | - def allow_all_students(self): | |
501 | + # ------------------------------------------------------------------------ | |
502 | + def allow_all_students(self) -> None: | |
570 | 503 | '''allow all students to login''' |
571 | - all_students = self._get_all_students() | |
572 | - self.allowed.update(s[0] for s in all_students) | |
573 | - logger.info('Allowed all %d students.', len(self.allowed)) | |
504 | + for student in self._students.values(): | |
505 | + student['state'] = 'allowed' | |
506 | + logger.info('Allowed %d students.', len(self._students)) | |
574 | 507 | |
575 | - def deny_all_students(self): | |
508 | + # ------------------------------------------------------------------------ | |
509 | + def deny_all_students(self) -> None: | |
576 | 510 | '''deny all students to login''' |
577 | 511 | logger.info('Denying all students...') |
578 | - self.allowed.clear() | |
512 | + for student in self._students.values(): | |
513 | + if student['state'] == 'allowed': | |
514 | + student['state'] = 'offline' | |
515 | + | |
516 | + # ------------------------------------------------------------------------ | |
517 | + def insert_new_student(self, uid: str, name: str) -> None: | |
518 | + '''insert new student into the database''' | |
519 | + with Session(self._engine, future=True) as session: | |
520 | + try: | |
521 | + session.add(Student(id=uid, name=name, password='')) | |
522 | + session.commit() | |
523 | + except IntegrityError: | |
524 | + logger.warning('"%s" already exists!', uid) | |
525 | + session.rollback() | |
526 | + return | |
527 | + logger.info('New student added: %s %s', uid, name) | |
528 | + self._students[uid] = {'name': name, 'state': 'offline', 'test': None} | |
579 | 529 | |
580 | - def allow_list(self, filename): | |
581 | - '''allow students listed in file (one number per line)''' | |
530 | + # ------------------------------------------------------------------------ | |
531 | + def allow_from_list(self, filename: str) -> None: | |
532 | + '''allow students listed in text file (one number per line)''' | |
582 | 533 | try: |
583 | - with open(filename, 'r') as file: | |
584 | - allowed_in_file = {s.strip() for s in file} - {''} | |
585 | - except Exception as exc: | |
534 | + with open(filename, 'r', encoding='utf-8') as file: | |
535 | + allowed = {line.strip() for line in file} | |
536 | + allowed.discard('') | |
537 | + except IOError as exc: | |
586 | 538 | error_msg = f'Cannot read file {filename}' |
587 | 539 | logger.critical(error_msg) |
588 | 540 | raise AppException(error_msg) from exc |
589 | 541 | |
590 | - enrolled = set(s[0] for s in self._get_all_students()) # in database | |
591 | - self.allowed.update(allowed_in_file & enrolled) | |
592 | - logger.info('Allowed %d students provided in "%s"', len(self.allowed), | |
593 | - filename) | |
594 | - | |
595 | - not_enrolled = allowed_in_file - enrolled | |
596 | - if not_enrolled: | |
597 | - logger.warning(' but found students not in the database: %s', | |
598 | - ', '.join(not_enrolled)) | |
599 | - | |
600 | - def _focus_student(self, uid): | |
601 | - '''set student in focus state''' | |
602 | - self.unfocus.discard(uid) | |
603 | - logger.info('"%s" focus', uid) | |
604 | - | |
605 | - def _unfocus_student(self, uid): | |
606 | - '''set student in unfocus state''' | |
607 | - self.unfocus.add(uid) | |
608 | - logger.info('"%s" unfocus', uid) | |
609 | - | |
610 | - def _set_screen_area(self, uid, sizes): | |
611 | - '''set current browser area as detected in resize event''' | |
612 | - scr_y, scr_x, win_y, win_x = sizes | |
613 | - area = win_x * win_y / (scr_x * scr_y) * 100 | |
614 | - self.area[uid] = area | |
615 | - logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', | |
616 | - uid, area, win_x, win_y, scr_x, scr_y) | |
617 | - | |
618 | - async def update_password(self, uid, password=''): | |
619 | - '''change password on the database''' | |
620 | - if password: | |
621 | - # password = await hash_password(password) | |
622 | - loop = asyncio.get_running_loop() | |
623 | - password = await loop.run_in_executor(None, | |
624 | - bcrypt.hashpw, | |
625 | - password.encode('utf-8'), | |
626 | - bcrypt.gensalt()) | |
627 | - | |
628 | - # with self._db_session() as sess: | |
629 | - # student = sess.query(Student).filter_by(id=uid).one() | |
630 | - with Session(self._engine, future=True) as session: | |
631 | - student = session.execute( | |
632 | - select(Student). | |
633 | - where(Student.id == uid) | |
634 | - ).scalar_one() | |
635 | - student.password = password | |
636 | - logger.info('"%s" password updated.', uid) | |
637 | - | |
638 | - def insert_new_student(self, uid, name): | |
639 | - '''insert new student into the database''' | |
640 | - with Session(self._engine, future=True) as session: | |
641 | - session.add( | |
642 | - Student(id=uid, name=name, password='') | |
643 | - ) | |
644 | - session.commit() | |
645 | - # try: | |
646 | - # with Session(self._engine, future=True) as session: | |
647 | - # session.add( | |
648 | - # Student(id=uid, name=name, password='') | |
649 | - # ) | |
650 | - # session.commit() | |
651 | - # except Exception: | |
652 | - # logger.error('Insert failed: student %s already exists?', uid) | |
653 | - # else: | |
654 | - # logger.info('New student: "%s", "%s"', uid, name) | |
542 | + missing = 0 | |
543 | + for uid in allowed: | |
544 | + try: | |
545 | + self.allow_student(uid) | |
546 | + except KeyError: | |
547 | + logger.warning('Allowed student "%s" does not exist!', uid) | |
548 | + missing += 1 | |
549 | + | |
550 | + logger.info('Allowed %d students', len(allowed)-missing) | |
551 | + if missing: | |
552 | + logger.warning(' %d missing!', missing) | |
553 | + | |
554 | + # def _focus_student(self, uid): | |
555 | + # '''set student in focus state''' | |
556 | + # self.unfocus.discard(uid) | |
557 | + # logger.info('"%s" focus', uid) | |
558 | + | |
559 | + # def _unfocus_student(self, uid): | |
560 | + # '''set student in unfocus state''' | |
561 | + # self.unfocus.add(uid) | |
562 | + # logger.info('"%s" unfocus', uid) | |
563 | + | |
564 | + # def _set_screen_area(self, uid, sizes): | |
565 | + # '''set current browser area as detected in resize event''' | |
566 | + # scr_y, scr_x, win_y, win_x = sizes | |
567 | + # area = win_x * win_y / (scr_x * scr_y) * 100 | |
568 | + # self.area[uid] = area | |
569 | + # logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', | |
570 | + # uid, area, win_x, win_y, scr_x, scr_y) | ... | ... |
perguntations/main.py
... | ... | @@ -10,7 +10,6 @@ import argparse |
10 | 10 | import logging |
11 | 11 | import logging.config |
12 | 12 | import os |
13 | -from os import environ, path | |
14 | 13 | import ssl |
15 | 14 | import sys |
16 | 15 | |
... | ... | @@ -62,54 +61,50 @@ def parse_cmdline_arguments(): |
62 | 61 | |
63 | 62 | |
64 | 63 | # ---------------------------------------------------------------------------- |
65 | -def get_logger_config(debug=False): | |
64 | +def get_logger_config(debug=False) -> dict: | |
66 | 65 | ''' |
67 | 66 | Load logger configuration from ~/.config directory if exists, |
68 | 67 | otherwise set default paramenters. |
69 | 68 | ''' |
69 | + | |
70 | + file = 'logger-debug.yaml' if debug else 'logger.yaml' | |
71 | + path = os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config/')) | |
72 | + try: | |
73 | + return load_yaml(os.path.join(path, APP_NAME, file)) | |
74 | + except IOError: | |
75 | + print('Using default logger configuration...') | |
76 | + | |
70 | 77 | if debug: |
71 | - filename = 'logger-debug.yaml' | |
72 | 78 | level = 'DEBUG' |
79 | + # fmt = '%(asctime)s | %(levelname)-8s | %(module)-16s:%(lineno)4d | %(thread)d | %(message)s' | |
80 | + fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s' | |
81 | + dateformat = '' | |
73 | 82 | else: |
74 | - filename = 'logger.yaml' | |
75 | 83 | level = 'INFO' |
76 | - | |
77 | - config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/') | |
78 | - config_file = path.join(path.expanduser(config_dir), APP_NAME, filename) | |
79 | - | |
80 | - default_config = { | |
81 | - 'version': 1, | |
82 | - 'formatters': { | |
83 | - 'standard': { | |
84 | - 'format': '%(asctime)s %(levelname)-8s %(message)s', | |
85 | - 'datefmt': '%H:%M:%S', | |
86 | - }, | |
87 | - }, | |
88 | - 'handlers': { | |
89 | - 'default': { | |
90 | - 'level': level, | |
91 | - 'class': 'logging.StreamHandler', | |
92 | - 'formatter': 'standard', | |
93 | - 'stream': 'ext://sys.stdout', | |
84 | + fmt = '%(asctime)s |%(levelname)-8s| %(message)s' | |
85 | + dateformat = '%Y-%m-%d %H:%M:%S' | |
86 | + modules = ['main', 'serve', 'app', 'models', 'questions', 'test', | |
87 | + 'testfactory', 'tools'] | |
88 | + logger = {'handlers': ['default'], 'level': level, 'propagate': False} | |
89 | + return { | |
90 | + 'version': 1, | |
91 | + 'formatters': { | |
92 | + 'standard': { | |
93 | + 'format': fmt, | |
94 | + 'datefmt': dateformat, | |
95 | + }, | |
94 | 96 | }, |
95 | - }, | |
96 | - 'loggers': { | |
97 | - '': { # configuration for serve.py | |
98 | - 'handlers': ['default'], | |
99 | - 'level': level, | |
97 | + 'handlers': { | |
98 | + 'default': { | |
99 | + 'level': level, | |
100 | + 'class': 'logging.StreamHandler', | |
101 | + 'formatter': 'standard', | |
102 | + 'stream': 'ext://sys.stdout', | |
103 | + }, | |
100 | 104 | }, |
101 | - }, | |
105 | + 'loggers': {f'{APP_NAME}.{module}': logger for module in modules} | |
102 | 106 | } |
103 | 107 | |
104 | - modules = ['app', 'models', 'questions', 'test', 'testfactory', 'tools'] | |
105 | - logger = {'handlers': ['default'], 'level': level, 'propagate': False} | |
106 | - | |
107 | - default_config['loggers'].update({f'{APP_NAME}.{module}': logger | |
108 | - for module in modules}) | |
109 | - | |
110 | - return load_yaml(config_file, default=default_config) | |
111 | - | |
112 | - | |
113 | 108 | # ---------------------------------------------------------------------------- |
114 | 109 | def main(): |
115 | 110 | ''' |
... | ... | @@ -121,7 +116,7 @@ def main(): |
121 | 116 | logging.config.dictConfig(get_logger_config(args.debug)) |
122 | 117 | logger = logging.getLogger(__name__) |
123 | 118 | |
124 | - logger.info('====================== Start Logging ======================') | |
119 | + logger.info('================== Start Logging ==================') | |
125 | 120 | |
126 | 121 | # --- start application -------------------------------------------------- |
127 | 122 | config = { |
... | ... | @@ -137,24 +132,24 @@ def main(): |
137 | 132 | try: |
138 | 133 | app = App(config) |
139 | 134 | except AppException: |
140 | - logger.critical('Failed to start application.') | |
135 | + logger.critical('Failed to start application!') | |
141 | 136 | sys.exit(1) |
142 | 137 | |
143 | 138 | # --- get SSL certificates ----------------------------------------------- |
144 | 139 | if 'XDG_DATA_HOME' in os.environ: |
145 | - certs_dir = path.join(os.environ['XDG_DATA_HOME'], 'certs') | |
140 | + certs_dir = os.path.join(os.environ['XDG_DATA_HOME'], 'certs') | |
146 | 141 | else: |
147 | - certs_dir = path.expanduser('~/.local/share/certs') | |
142 | + certs_dir = os.path.expanduser('~/.local/share/certs') | |
148 | 143 | |
149 | 144 | ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) |
150 | 145 | try: |
151 | - ssl_opt.load_cert_chain(path.join(certs_dir, 'cert.pem'), | |
152 | - path.join(certs_dir, 'privkey.pem')) | |
146 | + ssl_opt.load_cert_chain(os.path.join(certs_dir, 'cert.pem'), | |
147 | + os.path.join(certs_dir, 'privkey.pem')) | |
153 | 148 | except FileNotFoundError: |
154 | 149 | logger.critical('SSL certificates missing in %s', certs_dir) |
155 | 150 | sys.exit(1) |
156 | 151 | |
157 | - # --- run webserver ---------------------------------------------------- | |
152 | + # --- run webserver ------------------------------------------------------ | |
158 | 153 | run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug) |
159 | 154 | |
160 | 155 | ... | ... |
perguntations/questions.py
... | ... | @@ -363,7 +363,7 @@ class QuestionText(Question): |
363 | 363 | ans = ans.replace(' ', '') |
364 | 364 | elif transform == 'trim': # removes spaces around |
365 | 365 | ans = ans.strip() |
366 | - elif transform == 'normalize_space': # replaces multiple spaces by one | |
366 | + elif transform == 'normalize_space': # replaces many spaces by one | |
367 | 367 | ans = re.sub(r'\s+', ' ', ans.strip()) |
368 | 368 | elif transform == 'lower': # convert to lowercase |
369 | 369 | ans = ans.lower() | ... | ... |
perguntations/serve.py
... | ... | @@ -10,7 +10,7 @@ import asyncio |
10 | 10 | import base64 |
11 | 11 | import functools |
12 | 12 | import json |
13 | -import logging.config | |
13 | +import logging | |
14 | 14 | import mimetypes |
15 | 15 | from os import path |
16 | 16 | import re |
... | ... | @@ -22,13 +22,16 @@ import uuid |
22 | 22 | # user installed libraries |
23 | 23 | import tornado.ioloop |
24 | 24 | import tornado.web |
25 | -# import tornado.websocket | |
26 | 25 | import tornado.httpserver |
27 | 26 | |
28 | 27 | # this project |
29 | 28 | from perguntations.parser_markdown import md_to_html |
30 | 29 | |
31 | 30 | |
31 | +# setup logger for this module | |
32 | +logger = logging.getLogger(__name__) | |
33 | + | |
34 | + | |
32 | 35 | # ---------------------------------------------------------------------------- |
33 | 36 | class WebApplication(tornado.web.Application): |
34 | 37 | ''' |
... | ... | @@ -41,8 +44,6 @@ class WebApplication(tornado.web.Application): |
41 | 44 | (r'/review', ReviewHandler), |
42 | 45 | (r'/admin', AdminHandler), |
43 | 46 | (r'/file', FileHandler), |
44 | - # (r'/root', MainHandler), | |
45 | - # (r'/ws', AdminSocketHandler), | |
46 | 47 | (r'/adminwebservice', AdminWebservice), |
47 | 48 | (r'/studentwebservice', StudentWebservice), |
48 | 49 | (r'/', RootHandler), |
... | ... | @@ -64,8 +65,7 @@ class WebApplication(tornado.web.Application): |
64 | 65 | # ---------------------------------------------------------------------------- |
65 | 66 | def admin_only(func): |
66 | 67 | ''' |
67 | - Decorator used to restrict access to the administrator. | |
68 | - Example: | |
68 | + Decorator to restrict access to the administrator: | |
69 | 69 | |
70 | 70 | @admin_only |
71 | 71 | def get(self): ... |
... | ... | @@ -104,72 +104,16 @@ class BaseHandler(tornado.web.RequestHandler): |
104 | 104 | |
105 | 105 | |
106 | 106 | # ---------------------------------------------------------------------------- |
107 | -# class MainHandler(BaseHandler): | |
108 | - | |
109 | -# @tornado.web.authenticated | |
110 | -# @admin_only | |
111 | -# def get(self): | |
112 | -# self.render("admin-ws.html", students=self.testapp.get_students_state()) | |
113 | - | |
114 | - | |
115 | -# # ---------------------------------------------------------------------------- | |
116 | -# class AdminSocketHandler(tornado.websocket.WebSocketHandler): | |
117 | -# waiters = set() | |
118 | -# # cache = [] | |
119 | - | |
120 | -# # def get_compression_options(self): | |
121 | -# # return {} # Non-None enables compression with default options. | |
122 | - | |
123 | -# # called when opening connection | |
124 | -# def open(self): | |
125 | -# logging.debug('[AdminSocketHandler.open]') | |
126 | -# AdminSocketHandler.waiters.add(self) | |
127 | - | |
128 | -# # called when closing connection | |
129 | -# def on_close(self): | |
130 | -# logging.debug('[AdminSocketHandler.on_close]') | |
131 | -# AdminSocketHandler.waiters.remove(self) | |
132 | - | |
133 | -# # @classmethod | |
134 | -# # def update_cache(cls, chat): | |
135 | -# # logging.debug(f'[AdminSocketHandler.update_cache] "{chat}"') | |
136 | -# # cls.cache.append(chat) | |
137 | - | |
138 | -# # @classmethod | |
139 | -# # def send_updates(cls, chat): | |
140 | -# # logging.info("sending message to %d waiters", len(cls.waiters)) | |
141 | -# # for waiter in cls.waiters: | |
142 | -# # try: | |
143 | -# # waiter.write_message(chat) | |
144 | -# # except Exception: | |
145 | -# # logging.error("Error sending message", exc_info=True) | |
146 | - | |
147 | -# # handle incomming messages | |
148 | -# def on_message(self, message): | |
149 | -# logging.info(f"[AdminSocketHandler.onmessage] got message {message}") | |
150 | -# parsed = tornado.escape.json_decode(message) | |
151 | -# print(parsed) | |
152 | -# chat = {"id": str(uuid.uuid4()), "body": parsed["body"]} | |
153 | -# print(chat) | |
154 | -# chat["html"] = tornado.escape.to_basestring( | |
155 | -# '<div>' + chat['body'] + '</div>' | |
156 | -# # self.render_string("message.html", message=chat) | |
157 | -# ) | |
158 | -# print(chat) | |
159 | - | |
160 | -# AdminSocketHandler.update_cache(chat) # store msgs | |
161 | -# AdminSocketHandler.send_updates(chat) # send to clients | |
162 | - | |
163 | -# ---------------------------------------------------------------------------- | |
164 | 107 | # pylint: disable=abstract-method |
165 | 108 | class LoginHandler(BaseHandler): |
166 | 109 | '''Handles /login''' |
167 | 110 | |
168 | 111 | _prefix = re.compile(r'[a-z]') |
169 | 112 | _error_msg = { |
170 | - 'wrong_password': 'Password errada', | |
171 | - 'already_online': 'Já está online, não pode entrar duas vezes', | |
172 | - 'unauthorized': 'Não está autorizado a fazer o teste' | |
113 | + 'wrong_password': 'Senha errada', | |
114 | + # 'already_online': 'Já está online, não pode entrar duas vezes', | |
115 | + 'not allowed': 'Não está autorizado a fazer o teste', | |
116 | + 'nonexistent': 'Número de aluno inválido' | |
173 | 117 | } |
174 | 118 | |
175 | 119 | def get(self): |
... | ... | @@ -178,7 +122,8 @@ class LoginHandler(BaseHandler): |
178 | 122 | |
179 | 123 | async def post(self): |
180 | 124 | '''Authenticates student and login.''' |
181 | - uid = self._prefix.sub('', self.get_body_argument('uid')) | |
125 | + # uid = self._prefix.sub('', self.get_body_argument('uid')) | |
126 | + uid = self.get_body_argument('uid') | |
182 | 127 | password = self.get_body_argument('pw') |
183 | 128 | headers = { |
184 | 129 | 'remote_ip': self.request.remote_ip, |
... | ... | @@ -187,7 +132,7 @@ class LoginHandler(BaseHandler): |
187 | 132 | |
188 | 133 | error = await self.testapp.login(uid, password, headers) |
189 | 134 | |
190 | - if error: | |
135 | + if error is not None: | |
191 | 136 | await asyncio.sleep(3) # delay to avoid spamming the server... |
192 | 137 | self.render('login.html', error=self._error_msg[error]) |
193 | 138 | else: |
... | ... | @@ -245,14 +190,16 @@ class RootHandler(BaseHandler): |
245 | 190 | ''' |
246 | 191 | |
247 | 192 | uid = self.current_user |
248 | - logging.debug('"%s" GET /', uid) | |
193 | + logger.debug('"%s" GET /', uid) | |
249 | 194 | |
250 | 195 | if uid == '0': |
251 | 196 | self.redirect('/admin') |
252 | 197 | return |
253 | 198 | |
254 | - test = await self.testapp.get_test_or_generate(uid) | |
255 | - self.render('test.html', t=test, md=md_to_html, templ=self._templates) | |
199 | + test = self.testapp.get_test(uid) | |
200 | + name = self.testapp.get_name(uid) | |
201 | + self.render('test.html', | |
202 | + t=test, uid=uid, name=name, md=md_to_html, templ=self._templates) | |
256 | 203 | |
257 | 204 | |
258 | 205 | # --- POST |
... | ... | @@ -260,22 +207,21 @@ class RootHandler(BaseHandler): |
260 | 207 | async def post(self): |
261 | 208 | ''' |
262 | 209 | Receives answers, fixes some html weirdness, corrects test and |
263 | - sends back the grade. | |
210 | + renders the grade. | |
264 | 211 | |
265 | 212 | self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} |
266 | 213 | builds dictionary ans={0: 'answer0', 1:, 'answer1', ...} |
267 | 214 | unanswered questions not included. |
268 | 215 | ''' |
269 | - timeit_start = timer() # performance timer | |
216 | + starttime = timer() # performance timer | |
270 | 217 | |
271 | 218 | uid = self.current_user |
272 | - logging.debug('"%s" POST /', uid) | |
219 | + logger.debug('"%s" POST /', uid) | |
273 | 220 | |
274 | - try: | |
275 | - test = self.testapp.get_test(uid) | |
276 | - except KeyError as exc: | |
277 | - logging.warning('"%s" POST / raised 403 Forbidden', uid) | |
278 | - raise tornado.web.HTTPError(403) from exc # Forbidden | |
221 | + test = self.testapp.get_test(uid) | |
222 | + if test is None: | |
223 | + logger.warning('"%s" submitted but no test running - Err 403', uid) | |
224 | + raise tornado.web.HTTPError(403) # Forbidden | |
279 | 225 | |
280 | 226 | ans = {} |
281 | 227 | for i, question in enumerate(test['questions']): |
... | ... | @@ -296,16 +242,11 @@ class RootHandler(BaseHandler): |
296 | 242 | # submit answered questions, correct |
297 | 243 | await self.testapp.submit_test(uid, ans) |
298 | 244 | |
299 | - # show final grade and grades of other tests in the database | |
300 | - # allgrades = self.testapp.get_student_grades_from_all_tests(uid) | |
301 | - # grade = self.testapp.get_student_grade(uid) | |
302 | - | |
303 | - self.render('grade.html', t=test) | |
245 | + name = self.testapp.get_name(uid) | |
246 | + self.render('grade.html', t=test, uid=uid, name=name) | |
304 | 247 | self.clear_cookie('perguntations_user') |
305 | 248 | self.testapp.logout(uid) |
306 | - | |
307 | - timeit_finish = timer() | |
308 | - logging.info(' elapsed time: %fs', timeit_finish-timeit_start) | |
249 | + logger.info(' elapsed time: %fs', timer() - starttime) | |
309 | 250 | |
310 | 251 | |
311 | 252 | # ---------------------------------------------------------------------------- |
... | ... | @@ -337,8 +278,10 @@ class AdminWebservice(BaseHandler): |
337 | 278 | async def get(self): |
338 | 279 | '''admin webservices that do not change state''' |
339 | 280 | cmd = self.get_query_argument('cmd') |
281 | + logger.debug('GET /adminwebservice %s', cmd) | |
282 | + | |
340 | 283 | if cmd == 'testcsv': |
341 | - test_ref, data = self.testapp.get_test_csv() | |
284 | + test_ref, data = self.testapp.get_grades_csv() | |
342 | 285 | self.set_header('Content-Type', 'text/csv') |
343 | 286 | self.set_header('content-Disposition', |
344 | 287 | f'attachment; filename={test_ref}.csv') |
... | ... | @@ -346,7 +289,7 @@ class AdminWebservice(BaseHandler): |
346 | 289 | await self.flush() |
347 | 290 | |
348 | 291 | if cmd == 'questionscsv': |
349 | - test_ref, data = self.testapp.get_questions_csv() | |
292 | + test_ref, data = self.testapp.get_detailed_grades_csv() | |
350 | 293 | self.set_header('Content-Type', 'text/csv') |
351 | 294 | self.set_header('content-Disposition', |
352 | 295 | f'attachment; filename={test_ref}-detailed.csv') |
... | ... | @@ -359,6 +302,7 @@ class AdminWebservice(BaseHandler): |
359 | 302 | class AdminHandler(BaseHandler): |
360 | 303 | '''Handle /admin''' |
361 | 304 | |
305 | + # --- GET | |
362 | 306 | @tornado.web.authenticated |
363 | 307 | @admin_only |
364 | 308 | async def get(self): |
... | ... | @@ -366,24 +310,18 @@ class AdminHandler(BaseHandler): |
366 | 310 | Admin page. |
367 | 311 | ''' |
368 | 312 | cmd = self.get_query_argument('cmd', default=None) |
313 | + logger.debug('GET /admin (cmd=%s)', cmd) | |
369 | 314 | |
370 | - if cmd == 'students_table': | |
371 | - data = {'data': self.testapp.get_students_state()} | |
372 | - self.write(json.dumps(data, default=str)) | |
315 | + if cmd is None: | |
316 | + self.render('admin.html') | |
373 | 317 | elif cmd == 'test': |
374 | - data = { | |
375 | - 'data': { | |
376 | - 'title': self.testapp.testfactory['title'], | |
377 | - 'ref': self.testapp.testfactory['ref'], | |
378 | - 'filename': self.testapp.testfactory['testfile'], | |
379 | - 'database': self.testapp.testfactory['database'], | |
380 | - 'answers_dir': self.testapp.testfactory['answers_dir'], | |
381 | - } | |
382 | - } | |
318 | + data = { 'data': self.testapp.get_test_config() } | |
319 | + self.write(json.dumps(data, default=str)) | |
320 | + elif cmd == 'students_table': | |
321 | + data = {'data': self.testapp.get_students_state()} | |
383 | 322 | self.write(json.dumps(data, default=str)) |
384 | - else: | |
385 | - self.render('admin.html') | |
386 | 323 | |
324 | + # --- POST | |
387 | 325 | @tornado.web.authenticated |
388 | 326 | @admin_only |
389 | 327 | async def post(self): |
... | ... | @@ -392,6 +330,7 @@ class AdminHandler(BaseHandler): |
392 | 330 | ''' |
393 | 331 | cmd = self.get_body_argument('cmd', None) |
394 | 332 | value = self.get_body_argument('value', None) |
333 | + logger.debug('POST /admin (cmd=%s, value=%s)') | |
395 | 334 | |
396 | 335 | if cmd == 'allow': |
397 | 336 | self.testapp.allow_student(value) |
... | ... | @@ -402,15 +341,14 @@ class AdminHandler(BaseHandler): |
402 | 341 | elif cmd == 'deny_all': |
403 | 342 | self.testapp.deny_all_students() |
404 | 343 | elif cmd == 'reset_password': |
405 | - await self.testapp.update_student_password(uid=value, password='') | |
406 | - | |
407 | - elif cmd == 'insert_student': | |
344 | + await self.testapp.set_password(uid=value, pw='') | |
345 | + elif cmd == 'insert_student' and value is not None: | |
408 | 346 | student = json.loads(value) |
409 | 347 | self.testapp.insert_new_student(uid=student['number'], |
410 | 348 | name=student['name']) |
411 | 349 | |
412 | 350 | else: |
413 | - logging.error('Unknown command: "%s"', cmd) | |
351 | + logger.error('Unknown command: "%s"', cmd) | |
414 | 352 | |
415 | 353 | |
416 | 354 | # ---------------------------------------------------------------------------- |
... | ... | @@ -429,16 +367,16 @@ class FileHandler(BaseHandler): |
429 | 367 | Returns requested file. Files are obtained from the 'public' directory |
430 | 368 | of each question. |
431 | 369 | ''' |
432 | - | |
433 | 370 | uid = self.current_user |
434 | 371 | ref = self.get_query_argument('ref', None) |
435 | 372 | image = self.get_query_argument('image', None) |
373 | + logger.debug('GET /file (ref=%s, image=%s)', ref, image) | |
436 | 374 | content_type = mimetypes.guess_type(image)[0] |
437 | 375 | |
438 | 376 | if uid != '0': |
439 | 377 | test = self.testapp.get_student_test(uid) |
440 | 378 | else: |
441 | - logging.error('FIXME Cannot serve images for review.') | |
379 | + logger.error('FIXME Cannot serve images for review.') | |
442 | 380 | raise tornado.web.HTTPError(404) # FIXME admin |
443 | 381 | |
444 | 382 | if test is None: |
... | ... | @@ -447,15 +385,15 @@ class FileHandler(BaseHandler): |
447 | 385 | for question in test['questions']: |
448 | 386 | # search for the question that contains the image |
449 | 387 | if question['ref'] == ref: |
450 | - filepath = path.join(question['path'], 'public', image) | |
388 | + filepath = path.join(question['path'], b'public', image) | |
451 | 389 | try: |
452 | 390 | file = open(filepath, 'rb') |
453 | 391 | except FileNotFoundError: |
454 | - logging.error('File not found: %s', filepath) | |
392 | + logger.error('File not found: %s', filepath) | |
455 | 393 | except PermissionError: |
456 | - logging.error('No permission: %s', filepath) | |
394 | + logger.error('No permission: %s', filepath) | |
457 | 395 | except OSError: |
458 | - logging.error('Error opening file: %s', filepath) | |
396 | + logger.error('Error opening file: %s', filepath) | |
459 | 397 | else: |
460 | 398 | data = file.read() |
461 | 399 | file.close() |
... | ... | @@ -494,26 +432,29 @@ class ReviewHandler(BaseHandler): |
494 | 432 | Opens JSON file with a given corrected test and renders it |
495 | 433 | ''' |
496 | 434 | test_id = self.get_query_argument('test_id', None) |
497 | - logging.info('Review test %s.', test_id) | |
435 | + logger.info('Review test %s.', test_id) | |
498 | 436 | fname = self.testapp.get_json_filename_of_test(test_id) |
499 | 437 | |
500 | 438 | if fname is None: |
501 | 439 | raise tornado.web.HTTPError(404) # Not Found |
502 | 440 | |
503 | 441 | try: |
504 | - with open(path.expanduser(fname)) as jsonfile: | |
442 | + with open(path.expanduser(fname), encoding='utf-8') as jsonfile: | |
505 | 443 | test = json.load(jsonfile) |
506 | 444 | except OSError: |
507 | 445 | msg = f'Cannot open "{fname}" for review.' |
508 | - logging.error(msg) | |
446 | + logger.error(msg) | |
509 | 447 | raise tornado.web.HTTPError(status_code=404, reason=msg) from None |
510 | 448 | except json.JSONDecodeError as exc: |
511 | 449 | msg = f'JSON error in "{fname}": {exc}' |
512 | - logging.error(msg) | |
450 | + logger.error(msg) | |
513 | 451 | raise tornado.web.HTTPError(status_code=404, reason=msg) |
514 | 452 | |
515 | - self.render('review.html', t=test, md=md_to_html, | |
516 | - templ=self._templates) | |
453 | + uid = test['student'] | |
454 | + name = self.testapp.get_name(uid) | |
455 | + | |
456 | + self.render('review.html', t=test, uid=uid, name=name, | |
457 | + md=md_to_html, templ=self._templates) | |
517 | 458 | |
518 | 459 | |
519 | 460 | # ---------------------------------------------------------------------------- |
... | ... | @@ -524,7 +465,7 @@ def signal_handler(*_): |
524 | 465 | reply = input(' --> Stop webserver? (yes/no) ') |
525 | 466 | if reply.lower() == 'yes': |
526 | 467 | tornado.ioloop.IOLoop.current().stop() |
527 | - logging.critical('Webserver stopped.') | |
468 | + logger.critical('Webserver stopped.') | |
528 | 469 | sys.exit(0) |
529 | 470 | |
530 | 471 | # ---------------------------------------------------------------------------- |
... | ... | @@ -534,33 +475,33 @@ def run_webserver(app, ssl_opt, port, debug): |
534 | 475 | ''' |
535 | 476 | |
536 | 477 | # --- create web application |
537 | - logging.info('-----------------------------------------------------------') | |
538 | - logging.info('Starting WebApplication (tornado)') | |
478 | + logger.info('-------- Starting WebApplication (tornado) --------') | |
539 | 479 | try: |
540 | 480 | webapp = WebApplication(app, debug=debug) |
541 | 481 | except Exception: |
542 | - logging.critical('Failed to start web application.') | |
482 | + logger.critical('Failed to start web application.') | |
543 | 483 | raise |
544 | 484 | |
485 | + # --- create httpserver | |
545 | 486 | try: |
546 | 487 | httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt) |
547 | 488 | except ValueError: |
548 | - logging.critical('Certificates cert.pem, privkey.pem not found') | |
489 | + logger.critical('Certificates cert.pem, privkey.pem not found') | |
549 | 490 | sys.exit(1) |
550 | 491 | |
551 | 492 | try: |
552 | 493 | httpserver.listen(port) |
553 | 494 | except OSError: |
554 | - logging.critical('Cannot bind port %d. Already in use?', port) | |
495 | + logger.critical('Cannot bind port %d. Already in use?', port) | |
555 | 496 | sys.exit(1) |
556 | 497 | |
557 | - logging.info('Webserver listening on %d... (Ctrl-C to stop)', port) | |
498 | + logger.info('Listening on port %d... (Ctrl-C to stop)', port) | |
558 | 499 | signal.signal(signal.SIGINT, signal_handler) |
559 | 500 | |
560 | 501 | # --- run webserver |
561 | 502 | try: |
562 | 503 | tornado.ioloop.IOLoop.current().start() # running... |
563 | 504 | except Exception: |
564 | - logging.critical('Webserver stopped!') | |
505 | + logger.critical('Webserver stopped!') | |
565 | 506 | tornado.ioloop.IOLoop.current().stop() |
566 | 507 | raise | ... | ... |
perguntations/templates/admin.html
... | ... | @@ -72,7 +72,7 @@ |
72 | 72 | <p> |
73 | 73 | Referência: <code id="ref">--</code><br> |
74 | 74 | Ficheiro de configuração do teste: <code id="filename">--</code><br> |
75 | - Testes em formato JSON no directório: <code id="answers_dir">--</code><br> | |
75 | + Directório com os testes entregues: <code id="answers_dir">--</code><br> | |
76 | 76 | Base de dados: <code id="database">--</code><br> |
77 | 77 | </p> |
78 | 78 | <p> | ... | ... |
perguntations/templates/grade.html
... | ... | @@ -31,8 +31,8 @@ |
31 | 31 | </ul> |
32 | 32 | <span class="navbar-text"> |
33 | 33 | <i class="fas fa-user" aria-hidden="true"></i> |
34 | - <span id="name">{{ escape(t['student']['name']) }}</span> | |
35 | - (<span id="number">{{ escape(t['student']['number']) }}</span>) | |
34 | + <span id="name">{{ escape(name) }}</span> | |
35 | + (<span id="number">{{ escape(uid) }}</span>) | |
36 | 36 | <span class="caret"></span> |
37 | 37 | </span> |
38 | 38 | </div> | ... | ... |
perguntations/templates/review.html
... | ... | @@ -59,8 +59,8 @@ |
59 | 59 | <li class="nav-item"> |
60 | 60 | <span class="navbar-text"> |
61 | 61 | <i class="fas fa-user" aria-hidden="true"></i> |
62 | - <span id="name">{{ escape(t['student']['name']) }}</span> | |
63 | - (<span id="number">{{ escape(t['student']['number']) }}</span>) | |
62 | + <span id="name">{{ escape(name) }}</span> | |
63 | + (<span id="number">{{ escape(uid) }}</span>) | |
64 | 64 | <span class="caret"></span> |
65 | 65 | </span> |
66 | 66 | </li> | ... | ... |
perguntations/templates/test.html
... | ... | @@ -74,8 +74,8 @@ |
74 | 74 | <li class="nav-item"> |
75 | 75 | <span class="navbar-text"> |
76 | 76 | <i class="fas fa-user" aria-hidden="true"></i> |
77 | - <span id="name">{{ escape(t['student']['name']) }}</span> | |
78 | - (<span id="number">{{ escape(t['student']['number']) }}</span>) | |
77 | + <span id="name">{{ escape(name) }}</span> | |
78 | + (<span id="number">{{ escape(uid) }}</span>) | |
79 | 79 | <span class="caret"></span> |
80 | 80 | </span> |
81 | 81 | </li> |
... | ... | @@ -93,11 +93,11 @@ |
93 | 93 | |
94 | 94 | <div class="row"> |
95 | 95 | <label for="nome" class="col-sm-3">Nome:</label> |
96 | - <div class="col-sm-9" id="nome">{{ escape(t['student']['name']) }}</div> | |
96 | + <div class="col-sm-9" id="nome">{{ escape(name) }}</div> | |
97 | 97 | </div> |
98 | 98 | <div class="row"> |
99 | 99 | <label for="numero" class="col-sm-3">Número:</label> |
100 | - <div class="col-sm-9" id="numero">{{ escape(t['student']['number']) }}</div> | |
100 | + <div class="col-sm-9" id="numero">{{ escape(uid) }}</div> | |
101 | 101 | </div> |
102 | 102 | |
103 | 103 | <div class="row"> |
... | ... | @@ -119,7 +119,9 @@ |
119 | 119 | |
120 | 120 | <div class="form-row"> |
121 | 121 | <div class="col-12"> |
122 | - <button type="button" class="btn btn-success btn-lg btn-block" data-toggle="modal" data-target="#confirmar" id="form-button-submit">Submeter teste</button> | |
122 | + <button type="button" class="btn btn-success btn-lg btn-block" data-toggle="modal" data-target="#confirmar" id="form-button-submit"> | |
123 | + Submeter teste | |
124 | + </button> | |
123 | 125 | </div> |
124 | 126 | </div> |
125 | 127 | </form> |
... | ... | @@ -138,11 +140,16 @@ |
138 | 140 | <div class="modal-body"> |
139 | 141 | O teste será enviado para classificação e já não poderá voltar atrás. |
140 | 142 | Antes de submeter, verifique se respondeu a todas as questões. |
141 | - Desactive as perguntas que não pretende classificar para evitar eventuais penalizações. | |
143 | + Desactive as perguntas que não pretende classificar para evitar | |
144 | + eventuais penalizações. | |
142 | 145 | </div> |
143 | 146 | <div class="modal-footer"> |
144 | - <button type="button" class="btn btn-danger btn-lg" data-dismiss="modal">Oops, NÃO!!!</button> | |
145 | - <button form="test" type="submit" class="btn btn-success btn-lg">Sim, submeter...</button> | |
147 | + <button type="button" class="btn btn-danger btn-lg" data-dismiss="modal"> | |
148 | + Oops, NÃO!!! | |
149 | + </button> | |
150 | + <button form="test" type="submit" class="btn btn-success btn-lg"> | |
151 | + Sim, submeter... | |
152 | + </button> | |
146 | 153 | </div> |
147 | 154 | </div> |
148 | 155 | </div> | ... | ... |
perguntations/test.py
... | ... | @@ -7,7 +7,6 @@ from datetime import datetime |
7 | 7 | import json |
8 | 8 | import logging |
9 | 9 | from math import nan |
10 | -from os import path | |
11 | 10 | |
12 | 11 | # Logger configuration |
13 | 12 | logger = logging.getLogger(__name__) |
... | ... | @@ -26,11 +25,11 @@ class Test(dict): |
26 | 25 | self['comment'] = '' |
27 | 26 | |
28 | 27 | # ------------------------------------------------------------------------ |
29 | - def start(self, student: dict) -> None: | |
28 | + def start(self, uid: str) -> None: | |
30 | 29 | ''' |
31 | - Write student id in the test and register start time | |
30 | + Register student id and start time in the test | |
32 | 31 | ''' |
33 | - self['student'] = student | |
32 | + self['student'] = uid | |
34 | 33 | self['start_time'] = datetime.now() |
35 | 34 | self['finish_time'] = None |
36 | 35 | self['state'] = 'ACTIVE' | ... | ... |
perguntations/testfactory.py
... | ... | @@ -47,15 +47,15 @@ class TestFactory(dict): |
47 | 47 | 'duration': 0, # 0=infinite |
48 | 48 | 'autosubmit': False, |
49 | 49 | 'autocorrect': True, |
50 | - 'debug': False, | |
50 | + 'debug': False, # FIXME not property of a test... | |
51 | 51 | 'show_ref': False, |
52 | 52 | }) |
53 | 53 | self.update(conf) |
54 | 54 | |
55 | 55 | # --- for review, we are done. no factories needed |
56 | - if self['review']: | |
57 | - logger.info('Review mode. No questions loaded. No factories.') | |
58 | - return | |
56 | + # if self['review']: FIXME | |
57 | + # logger.info('Review mode. No questions loaded. No factories.') | |
58 | + # return | |
59 | 59 | |
60 | 60 | # --- perform sanity checks and normalize the test questions |
61 | 61 | self.sanity_checks() | ... | ... |
perguntations/tools.py
... | ... | @@ -19,28 +19,11 @@ import yaml |
19 | 19 | logger = logging.getLogger(__name__) |
20 | 20 | |
21 | 21 | |
22 | -# --------------------------------------------------------------------------- | |
23 | -def load_yaml(filename: str, default: Any = None) -> Any: | |
24 | - '''load data from yaml file''' | |
25 | - | |
26 | - filename = path.expanduser(filename) | |
27 | - try: | |
28 | - file = open(filename, 'r', encoding='utf-8') | |
29 | - except Exception as exc: | |
30 | - logger.error(exc) | |
31 | - if default is not None: | |
32 | - return default | |
33 | - raise | |
34 | - | |
35 | - with file: | |
36 | - try: | |
37 | - return yaml.safe_load(file) | |
38 | - except yaml.YAMLError as exc: | |
39 | - logger.error(str(exc).replace('\n', ' ')) | |
40 | - if default is not None: | |
41 | - return default | |
42 | - raise | |
43 | - | |
22 | +# ---------------------------------------------------------------------------- | |
23 | +def load_yaml(filename: str) -> Any: | |
24 | + '''load yaml file or raise exception on error''' | |
25 | + with open(path.expanduser(filename), 'r', encoding='utf-8') as file: | |
26 | + return yaml.safe_load(file) | |
44 | 27 | |
45 | 28 | # --------------------------------------------------------------------------- |
46 | 29 | def run_script(script: str, | ... | ... |