Commit 7ce13d3942fec48f3be503b1feef077a4a024027

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

In the process of converting to sqlalchemy 1.4

Functional, but notifications (unfocus, password defined, etc) are not
yet implemented.
demo/questions/generate-question.py 0 → 100755
@@ -0,0 +1,77 @@ @@ -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,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,7 +586,7 @@
586 # ---------------------------------------------------------------------------- 586 # ----------------------------------------------------------------------------
587 - type: generator 587 - type: generator
588 ref: tut-generator 588 ref: tut-generator
589 - script: generators/generate-question.py 589 + script: generate-question.py
590 args: [1, 100] 590 args: [1, 100]
591 591
592 # ---------------------------------------------------------------------------- 592 # ----------------------------------------------------------------------------
perguntations/app.py
@@ -8,49 +8,41 @@ Description: Main application logic. @@ -8,49 +8,41 @@ Description: Main application logic.
8 import asyncio 8 import asyncio
9 import csv 9 import csv
10 import io 10 import io
11 -import json  
12 import logging 11 import logging
13 -from os import path 12 +import os
  13 +from typing import Optional
14 14
15 # installed packages 15 # installed packages
16 import bcrypt 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 from sqlalchemy.orm import Session 19 from sqlalchemy.orm import Session
19 -# from sqlalchemy.exc import NoResultFound 20 +import yaml
20 21
21 # this project 22 # this project
22 from perguntations.models import Student, Test, Question 23 from perguntations.models import Student, Test, Question
23 -from perguntations.questions import question_from  
24 from perguntations.tools import load_yaml 24 from perguntations.tools import load_yaml
25 from perguntations.testfactory import TestFactory, TestFactoryException 25 from perguntations.testfactory import TestFactory, TestFactoryException
26 -import perguntations.test  
27 26
28 # setup logger for this module 27 # setup logger for this module
29 logger = logging.getLogger(__name__) 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,279 +50,154 @@ class AppException(Exception):
58 # ============================================================================ 50 # ============================================================================
59 class App(): 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 self.allow_all_students() 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 else: 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 def _db_setup(self) -> None: 73 def _db_setup(self) -> None:
120 - logger.info('Setup database') 74 + logger.debug('Checking database...')
121 75
122 # connect to database and check registered students 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 self._engine = create_engine(f'sqlite:///{dbfile}', future=True) 80 self._engine = create_engine(f'sqlite:///{dbfile}', future=True)
127 -  
128 try: 81 try:
129 with Session(self._engine, future=True) as session: 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 return 'wrong_password' 131 return 'wrong_password'
225 132
226 # success 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 '''student logout''' 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 logger.info('"%s" logged out.', uid) 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 Setup a factory for the test 168 Setup a factory for the test
251 ''' 169 '''
252 170
253 # load configuration from yaml file 171 # load configuration from yaml file
254 - logger.info('Loading test configuration "%s".', conf["testfile"])  
255 try: 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 raise AppException(msg) from exc 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 logger.info('Running test factory...') 181 logger.info('Running test factory...')
267 try: 182 try:
268 - self.testfactory = TestFactory(testconf) 183 + self._testfactory = TestFactory(testconf)
269 except TestFactoryException as exc: 184 except TestFactoryException as exc:
270 - logger.critical(exc) 185 + logger.error(exc)
271 raise AppException('Failed to create test factory!') from exc 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 Handles test submission and correction. 191 Handles test submission and correction.
325 192
326 ans is a dictionary {question_index: answer, ...} with the answers for 193 ans is a dictionary {question_index: answer, ...} with the answers for
327 the complete test. For example: {0:'hello', 1:[1,2]} 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 # --- submit answers and correct test 198 # --- submit answers and correct test
  199 + test = self._students[uid]['test']
332 test.submit(ans) 200 test.submit(ans)
333 - logger.info('"%s" submitted %d answers.', uid, len(ans))  
334 201
335 if test['autocorrect']: 202 if test['autocorrect']:
336 await test.correct_async() 203 await test.correct_async()
@@ -338,7 +205,7 @@ class App(): @@ -338,7 +205,7 @@ class App():
338 205
339 # --- save test in JSON format 206 # --- save test in JSON format
340 fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json' 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 test.save_json(fpath) 209 test.save_json(fpath)
343 logger.info('"%s" saved JSON.', uid) 210 logger.info('"%s" saved JSON.', uid)
344 211
@@ -374,9 +241,66 @@ class App(): @@ -374,9 +241,66 @@ class App():
374 session.commit() 241 session.commit()
375 logger.info('"%s" database updated.', uid) 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 # def giveup_test(self, uid): 306 # def giveup_test(self, uid):
@@ -388,7 +312,7 @@ class App(): @@ -388,7 +312,7 @@ class App():
388 # fields = (test['student']['number'], test['ref'], 312 # fields = (test['student']['number'], test['ref'],
389 # str(test['finish_time'])) 313 # str(test['finish_time']))
390 # fname = '--'.join(fields) + '.json' 314 # fname = '--'.join(fields) + '.json'
391 - # fpath = path.join(test['answers_dir'], fname) 315 + # fpath = os.path.join(test['answers_dir'], fname)
392 # test.save_json(fpath) 316 # test.save_json(fpath)
393 317
394 # # insert test into database 318 # # insert test into database
@@ -409,137 +333,143 @@ class App(): @@ -409,137 +333,143 @@ class App():
409 # ------------------------------------------------------------------------ 333 # ------------------------------------------------------------------------
410 def event_test(self, uid, cmd, value): 334 def event_test(self, uid, cmd, value):
411 '''handles browser events the occur during the test''' 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 with Session(self._engine, future=True) as session: 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 if not tests: 376 if not tests:
456 logger.warning('Empty CSV: there are no tests!') 377 logger.warning('Empty CSV: there are no tests!')
457 return test_ref, '' 378 return test_ref, ''
458 379
459 - cols = ['Aluno', 'Início'] + list(qnums)  
460 -  
461 csvstr = io.StringIO() 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 return test_ref, csvstr.getvalue() 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 with Session(self._engine, future=True) as session: 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 if not tests: 405 if not tests:
487 logger.warning('Empty CSV: there are no tests!') 406 logger.warning('Empty CSV: there are no tests!')
488 return test_ref, '' 407 return test_ref, ''
489 408
490 csvstr = io.StringIO() 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 return test_ref, csvstr.getvalue() 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 def get_json_filename_of_test(self, test_id): 417 def get_json_filename_of_test(self, test_id):
506 '''get JSON filename from database given the test_id''' 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 '''get grades of student for a given testid''' 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 '''get list of states of every student''' 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 # --- private methods ---------------------------------------------------- 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 # def get_allowed_students(self): 474 # def get_allowed_students(self):
545 # # set of 'uid' allowed to login 475 # # set of 'uid' allowed to login
@@ -550,105 +480,91 @@ class App(): @@ -550,105 +480,91 @@ class App():
550 # t = self.get_student_test(uid) 480 # t = self.get_student_test(uid)
551 # for q in t['questions']: 481 # for q in t['questions']:
552 # if q['ref'] == ref and key in q['files']: 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 '''allow a single student to login''' 489 '''allow a single student to login'''
561 - self.allowed.add(uid) 490 + self._students[uid]['state'] = 'allowed'
562 logger.info('"%s" allowed to login.', uid) 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 '''deny a single student to login''' 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 logger.info('"%s" denied to login', uid) 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 '''allow all students to login''' 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 '''deny all students to login''' 510 '''deny all students to login'''
577 logger.info('Denying all students...') 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 try: 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 error_msg = f'Cannot read file {filename}' 538 error_msg = f'Cannot read file {filename}'
587 logger.critical(error_msg) 539 logger.critical(error_msg)
588 raise AppException(error_msg) from exc 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,7 +10,6 @@ import argparse
10 import logging 10 import logging
11 import logging.config 11 import logging.config
12 import os 12 import os
13 -from os import environ, path  
14 import ssl 13 import ssl
15 import sys 14 import sys
16 15
@@ -62,54 +61,50 @@ def parse_cmdline_arguments(): @@ -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 Load logger configuration from ~/.config directory if exists, 66 Load logger configuration from ~/.config directory if exists,
68 otherwise set default paramenters. 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 if debug: 77 if debug:
71 - filename = 'logger-debug.yaml'  
72 level = 'DEBUG' 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 else: 82 else:
74 - filename = 'logger.yaml'  
75 level = 'INFO' 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 def main(): 109 def main():
115 ''' 110 '''
@@ -121,7 +116,7 @@ def main(): @@ -121,7 +116,7 @@ def main():
121 logging.config.dictConfig(get_logger_config(args.debug)) 116 logging.config.dictConfig(get_logger_config(args.debug))
122 logger = logging.getLogger(__name__) 117 logger = logging.getLogger(__name__)
123 118
124 - logger.info('====================== Start Logging ======================') 119 + logger.info('================== Start Logging ==================')
125 120
126 # --- start application -------------------------------------------------- 121 # --- start application --------------------------------------------------
127 config = { 122 config = {
@@ -137,24 +132,24 @@ def main(): @@ -137,24 +132,24 @@ def main():
137 try: 132 try:
138 app = App(config) 133 app = App(config)
139 except AppException: 134 except AppException:
140 - logger.critical('Failed to start application.') 135 + logger.critical('Failed to start application!')
141 sys.exit(1) 136 sys.exit(1)
142 137
143 # --- get SSL certificates ----------------------------------------------- 138 # --- get SSL certificates -----------------------------------------------
144 if 'XDG_DATA_HOME' in os.environ: 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 else: 141 else:
147 - certs_dir = path.expanduser('~/.local/share/certs') 142 + certs_dir = os.path.expanduser('~/.local/share/certs')
148 143
149 ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) 144 ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
150 try: 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 except FileNotFoundError: 148 except FileNotFoundError:
154 logger.critical('SSL certificates missing in %s', certs_dir) 149 logger.critical('SSL certificates missing in %s', certs_dir)
155 sys.exit(1) 150 sys.exit(1)
156 151
157 - # --- run webserver ---------------------------------------------------- 152 + # --- run webserver ------------------------------------------------------
158 run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug) 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,7 +363,7 @@ class QuestionText(Question):
363 ans = ans.replace(' ', '') 363 ans = ans.replace(' ', '')
364 elif transform == 'trim': # removes spaces around 364 elif transform == 'trim': # removes spaces around
365 ans = ans.strip() 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 ans = re.sub(r'\s+', ' ', ans.strip()) 367 ans = re.sub(r'\s+', ' ', ans.strip())
368 elif transform == 'lower': # convert to lowercase 368 elif transform == 'lower': # convert to lowercase
369 ans = ans.lower() 369 ans = ans.lower()
perguntations/serve.py
@@ -10,7 +10,7 @@ import asyncio @@ -10,7 +10,7 @@ import asyncio
10 import base64 10 import base64
11 import functools 11 import functools
12 import json 12 import json
13 -import logging.config 13 +import logging
14 import mimetypes 14 import mimetypes
15 from os import path 15 from os import path
16 import re 16 import re
@@ -22,13 +22,16 @@ import uuid @@ -22,13 +22,16 @@ import uuid
22 # user installed libraries 22 # user installed libraries
23 import tornado.ioloop 23 import tornado.ioloop
24 import tornado.web 24 import tornado.web
25 -# import tornado.websocket  
26 import tornado.httpserver 25 import tornado.httpserver
27 26
28 # this project 27 # this project
29 from perguntations.parser_markdown import md_to_html 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 class WebApplication(tornado.web.Application): 36 class WebApplication(tornado.web.Application):
34 ''' 37 '''
@@ -41,8 +44,6 @@ class WebApplication(tornado.web.Application): @@ -41,8 +44,6 @@ class WebApplication(tornado.web.Application):
41 (r'/review', ReviewHandler), 44 (r'/review', ReviewHandler),
42 (r'/admin', AdminHandler), 45 (r'/admin', AdminHandler),
43 (r'/file', FileHandler), 46 (r'/file', FileHandler),
44 - # (r'/root', MainHandler),  
45 - # (r'/ws', AdminSocketHandler),  
46 (r'/adminwebservice', AdminWebservice), 47 (r'/adminwebservice', AdminWebservice),
47 (r'/studentwebservice', StudentWebservice), 48 (r'/studentwebservice', StudentWebservice),
48 (r'/', RootHandler), 49 (r'/', RootHandler),
@@ -64,8 +65,7 @@ class WebApplication(tornado.web.Application): @@ -64,8 +65,7 @@ class WebApplication(tornado.web.Application):
64 # ---------------------------------------------------------------------------- 65 # ----------------------------------------------------------------------------
65 def admin_only(func): 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 @admin_only 70 @admin_only
71 def get(self): ... 71 def get(self): ...
@@ -104,72 +104,16 @@ class BaseHandler(tornado.web.RequestHandler): @@ -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 # pylint: disable=abstract-method 107 # pylint: disable=abstract-method
165 class LoginHandler(BaseHandler): 108 class LoginHandler(BaseHandler):
166 '''Handles /login''' 109 '''Handles /login'''
167 110
168 _prefix = re.compile(r'[a-z]') 111 _prefix = re.compile(r'[a-z]')
169 _error_msg = { 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 def get(self): 119 def get(self):
@@ -178,7 +122,8 @@ class LoginHandler(BaseHandler): @@ -178,7 +122,8 @@ class LoginHandler(BaseHandler):
178 122
179 async def post(self): 123 async def post(self):
180 '''Authenticates student and login.''' 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 password = self.get_body_argument('pw') 127 password = self.get_body_argument('pw')
183 headers = { 128 headers = {
184 'remote_ip': self.request.remote_ip, 129 'remote_ip': self.request.remote_ip,
@@ -187,7 +132,7 @@ class LoginHandler(BaseHandler): @@ -187,7 +132,7 @@ class LoginHandler(BaseHandler):
187 132
188 error = await self.testapp.login(uid, password, headers) 133 error = await self.testapp.login(uid, password, headers)
189 134
190 - if error: 135 + if error is not None:
191 await asyncio.sleep(3) # delay to avoid spamming the server... 136 await asyncio.sleep(3) # delay to avoid spamming the server...
192 self.render('login.html', error=self._error_msg[error]) 137 self.render('login.html', error=self._error_msg[error])
193 else: 138 else:
@@ -245,14 +190,16 @@ class RootHandler(BaseHandler): @@ -245,14 +190,16 @@ class RootHandler(BaseHandler):
245 ''' 190 '''
246 191
247 uid = self.current_user 192 uid = self.current_user
248 - logging.debug('"%s" GET /', uid) 193 + logger.debug('"%s" GET /', uid)
249 194
250 if uid == '0': 195 if uid == '0':
251 self.redirect('/admin') 196 self.redirect('/admin')
252 return 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 # --- POST 205 # --- POST
@@ -260,22 +207,21 @@ class RootHandler(BaseHandler): @@ -260,22 +207,21 @@ class RootHandler(BaseHandler):
260 async def post(self): 207 async def post(self):
261 ''' 208 '''
262 Receives answers, fixes some html weirdness, corrects test and 209 Receives answers, fixes some html weirdness, corrects test and
263 - sends back the grade. 210 + renders the grade.
264 211
265 self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} 212 self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']}
266 builds dictionary ans={0: 'answer0', 1:, 'answer1', ...} 213 builds dictionary ans={0: 'answer0', 1:, 'answer1', ...}
267 unanswered questions not included. 214 unanswered questions not included.
268 ''' 215 '''
269 - timeit_start = timer() # performance timer 216 + starttime = timer() # performance timer
270 217
271 uid = self.current_user 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 ans = {} 226 ans = {}
281 for i, question in enumerate(test['questions']): 227 for i, question in enumerate(test['questions']):
@@ -296,16 +242,11 @@ class RootHandler(BaseHandler): @@ -296,16 +242,11 @@ class RootHandler(BaseHandler):
296 # submit answered questions, correct 242 # submit answered questions, correct
297 await self.testapp.submit_test(uid, ans) 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 self.clear_cookie('perguntations_user') 247 self.clear_cookie('perguntations_user')
305 self.testapp.logout(uid) 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,8 +278,10 @@ class AdminWebservice(BaseHandler):
337 async def get(self): 278 async def get(self):
338 '''admin webservices that do not change state''' 279 '''admin webservices that do not change state'''
339 cmd = self.get_query_argument('cmd') 280 cmd = self.get_query_argument('cmd')
  281 + logger.debug('GET /adminwebservice %s', cmd)
  282 +
340 if cmd == 'testcsv': 283 if cmd == 'testcsv':
341 - test_ref, data = self.testapp.get_test_csv() 284 + test_ref, data = self.testapp.get_grades_csv()
342 self.set_header('Content-Type', 'text/csv') 285 self.set_header('Content-Type', 'text/csv')
343 self.set_header('content-Disposition', 286 self.set_header('content-Disposition',
344 f'attachment; filename={test_ref}.csv') 287 f'attachment; filename={test_ref}.csv')
@@ -346,7 +289,7 @@ class AdminWebservice(BaseHandler): @@ -346,7 +289,7 @@ class AdminWebservice(BaseHandler):
346 await self.flush() 289 await self.flush()
347 290
348 if cmd == 'questionscsv': 291 if cmd == 'questionscsv':
349 - test_ref, data = self.testapp.get_questions_csv() 292 + test_ref, data = self.testapp.get_detailed_grades_csv()
350 self.set_header('Content-Type', 'text/csv') 293 self.set_header('Content-Type', 'text/csv')
351 self.set_header('content-Disposition', 294 self.set_header('content-Disposition',
352 f'attachment; filename={test_ref}-detailed.csv') 295 f'attachment; filename={test_ref}-detailed.csv')
@@ -359,6 +302,7 @@ class AdminWebservice(BaseHandler): @@ -359,6 +302,7 @@ class AdminWebservice(BaseHandler):
359 class AdminHandler(BaseHandler): 302 class AdminHandler(BaseHandler):
360 '''Handle /admin''' 303 '''Handle /admin'''
361 304
  305 + # --- GET
362 @tornado.web.authenticated 306 @tornado.web.authenticated
363 @admin_only 307 @admin_only
364 async def get(self): 308 async def get(self):
@@ -366,24 +310,18 @@ class AdminHandler(BaseHandler): @@ -366,24 +310,18 @@ class AdminHandler(BaseHandler):
366 Admin page. 310 Admin page.
367 ''' 311 '''
368 cmd = self.get_query_argument('cmd', default=None) 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 elif cmd == 'test': 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 self.write(json.dumps(data, default=str)) 322 self.write(json.dumps(data, default=str))
384 - else:  
385 - self.render('admin.html')  
386 323
  324 + # --- POST
387 @tornado.web.authenticated 325 @tornado.web.authenticated
388 @admin_only 326 @admin_only
389 async def post(self): 327 async def post(self):
@@ -392,6 +330,7 @@ class AdminHandler(BaseHandler): @@ -392,6 +330,7 @@ class AdminHandler(BaseHandler):
392 ''' 330 '''
393 cmd = self.get_body_argument('cmd', None) 331 cmd = self.get_body_argument('cmd', None)
394 value = self.get_body_argument('value', None) 332 value = self.get_body_argument('value', None)
  333 + logger.debug('POST /admin (cmd=%s, value=%s)')
395 334
396 if cmd == 'allow': 335 if cmd == 'allow':
397 self.testapp.allow_student(value) 336 self.testapp.allow_student(value)
@@ -402,15 +341,14 @@ class AdminHandler(BaseHandler): @@ -402,15 +341,14 @@ class AdminHandler(BaseHandler):
402 elif cmd == 'deny_all': 341 elif cmd == 'deny_all':
403 self.testapp.deny_all_students() 342 self.testapp.deny_all_students()
404 elif cmd == 'reset_password': 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 student = json.loads(value) 346 student = json.loads(value)
409 self.testapp.insert_new_student(uid=student['number'], 347 self.testapp.insert_new_student(uid=student['number'],
410 name=student['name']) 348 name=student['name'])
411 349
412 else: 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,16 +367,16 @@ class FileHandler(BaseHandler):
429 Returns requested file. Files are obtained from the 'public' directory 367 Returns requested file. Files are obtained from the 'public' directory
430 of each question. 368 of each question.
431 ''' 369 '''
432 -  
433 uid = self.current_user 370 uid = self.current_user
434 ref = self.get_query_argument('ref', None) 371 ref = self.get_query_argument('ref', None)
435 image = self.get_query_argument('image', None) 372 image = self.get_query_argument('image', None)
  373 + logger.debug('GET /file (ref=%s, image=%s)', ref, image)
436 content_type = mimetypes.guess_type(image)[0] 374 content_type = mimetypes.guess_type(image)[0]
437 375
438 if uid != '0': 376 if uid != '0':
439 test = self.testapp.get_student_test(uid) 377 test = self.testapp.get_student_test(uid)
440 else: 378 else:
441 - logging.error('FIXME Cannot serve images for review.') 379 + logger.error('FIXME Cannot serve images for review.')
442 raise tornado.web.HTTPError(404) # FIXME admin 380 raise tornado.web.HTTPError(404) # FIXME admin
443 381
444 if test is None: 382 if test is None:
@@ -447,15 +385,15 @@ class FileHandler(BaseHandler): @@ -447,15 +385,15 @@ class FileHandler(BaseHandler):
447 for question in test['questions']: 385 for question in test['questions']:
448 # search for the question that contains the image 386 # search for the question that contains the image
449 if question['ref'] == ref: 387 if question['ref'] == ref:
450 - filepath = path.join(question['path'], 'public', image) 388 + filepath = path.join(question['path'], b'public', image)
451 try: 389 try:
452 file = open(filepath, 'rb') 390 file = open(filepath, 'rb')
453 except FileNotFoundError: 391 except FileNotFoundError:
454 - logging.error('File not found: %s', filepath) 392 + logger.error('File not found: %s', filepath)
455 except PermissionError: 393 except PermissionError:
456 - logging.error('No permission: %s', filepath) 394 + logger.error('No permission: %s', filepath)
457 except OSError: 395 except OSError:
458 - logging.error('Error opening file: %s', filepath) 396 + logger.error('Error opening file: %s', filepath)
459 else: 397 else:
460 data = file.read() 398 data = file.read()
461 file.close() 399 file.close()
@@ -494,26 +432,29 @@ class ReviewHandler(BaseHandler): @@ -494,26 +432,29 @@ class ReviewHandler(BaseHandler):
494 Opens JSON file with a given corrected test and renders it 432 Opens JSON file with a given corrected test and renders it
495 ''' 433 '''
496 test_id = self.get_query_argument('test_id', None) 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 fname = self.testapp.get_json_filename_of_test(test_id) 436 fname = self.testapp.get_json_filename_of_test(test_id)
499 437
500 if fname is None: 438 if fname is None:
501 raise tornado.web.HTTPError(404) # Not Found 439 raise tornado.web.HTTPError(404) # Not Found
502 440
503 try: 441 try:
504 - with open(path.expanduser(fname)) as jsonfile: 442 + with open(path.expanduser(fname), encoding='utf-8') as jsonfile:
505 test = json.load(jsonfile) 443 test = json.load(jsonfile)
506 except OSError: 444 except OSError:
507 msg = f'Cannot open "{fname}" for review.' 445 msg = f'Cannot open "{fname}" for review.'
508 - logging.error(msg) 446 + logger.error(msg)
509 raise tornado.web.HTTPError(status_code=404, reason=msg) from None 447 raise tornado.web.HTTPError(status_code=404, reason=msg) from None
510 except json.JSONDecodeError as exc: 448 except json.JSONDecodeError as exc:
511 msg = f'JSON error in "{fname}": {exc}' 449 msg = f'JSON error in "{fname}": {exc}'
512 - logging.error(msg) 450 + logger.error(msg)
513 raise tornado.web.HTTPError(status_code=404, reason=msg) 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,7 +465,7 @@ def signal_handler(*_):
524 reply = input(' --> Stop webserver? (yes/no) ') 465 reply = input(' --> Stop webserver? (yes/no) ')
525 if reply.lower() == 'yes': 466 if reply.lower() == 'yes':
526 tornado.ioloop.IOLoop.current().stop() 467 tornado.ioloop.IOLoop.current().stop()
527 - logging.critical('Webserver stopped.') 468 + logger.critical('Webserver stopped.')
528 sys.exit(0) 469 sys.exit(0)
529 470
530 # ---------------------------------------------------------------------------- 471 # ----------------------------------------------------------------------------
@@ -534,33 +475,33 @@ def run_webserver(app, ssl_opt, port, debug): @@ -534,33 +475,33 @@ def run_webserver(app, ssl_opt, port, debug):
534 ''' 475 '''
535 476
536 # --- create web application 477 # --- create web application
537 - logging.info('-----------------------------------------------------------')  
538 - logging.info('Starting WebApplication (tornado)') 478 + logger.info('-------- Starting WebApplication (tornado) --------')
539 try: 479 try:
540 webapp = WebApplication(app, debug=debug) 480 webapp = WebApplication(app, debug=debug)
541 except Exception: 481 except Exception:
542 - logging.critical('Failed to start web application.') 482 + logger.critical('Failed to start web application.')
543 raise 483 raise
544 484
  485 + # --- create httpserver
545 try: 486 try:
546 httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt) 487 httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt)
547 except ValueError: 488 except ValueError:
548 - logging.critical('Certificates cert.pem, privkey.pem not found') 489 + logger.critical('Certificates cert.pem, privkey.pem not found')
549 sys.exit(1) 490 sys.exit(1)
550 491
551 try: 492 try:
552 httpserver.listen(port) 493 httpserver.listen(port)
553 except OSError: 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 sys.exit(1) 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 signal.signal(signal.SIGINT, signal_handler) 499 signal.signal(signal.SIGINT, signal_handler)
559 500
560 # --- run webserver 501 # --- run webserver
561 try: 502 try:
562 tornado.ioloop.IOLoop.current().start() # running... 503 tornado.ioloop.IOLoop.current().start() # running...
563 except Exception: 504 except Exception:
564 - logging.critical('Webserver stopped!') 505 + logger.critical('Webserver stopped!')
565 tornado.ioloop.IOLoop.current().stop() 506 tornado.ioloop.IOLoop.current().stop()
566 raise 507 raise
perguntations/templates/admin.html
@@ -72,7 +72,7 @@ @@ -72,7 +72,7 @@
72 <p> 72 <p>
73 Referência: <code id="ref">--</code><br> 73 Referência: <code id="ref">--</code><br>
74 Ficheiro de configuração do teste: <code id="filename">--</code><br> 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 Base de dados: <code id="database">--</code><br> 76 Base de dados: <code id="database">--</code><br>
77 </p> 77 </p>
78 <p> 78 <p>
perguntations/templates/grade.html
@@ -31,8 +31,8 @@ @@ -31,8 +31,8 @@
31 </ul> 31 </ul>
32 <span class="navbar-text"> 32 <span class="navbar-text">
33 <i class="fas fa-user" aria-hidden="true"></i> 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 <span class="caret"></span> 36 <span class="caret"></span>
37 </span> 37 </span>
38 </div> 38 </div>
perguntations/templates/review.html
@@ -59,8 +59,8 @@ @@ -59,8 +59,8 @@
59 <li class="nav-item"> 59 <li class="nav-item">
60 <span class="navbar-text"> 60 <span class="navbar-text">
61 <i class="fas fa-user" aria-hidden="true"></i> 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 <span class="caret"></span> 64 <span class="caret"></span>
65 </span> 65 </span>
66 </li> 66 </li>
perguntations/templates/test.html
@@ -74,8 +74,8 @@ @@ -74,8 +74,8 @@
74 <li class="nav-item"> 74 <li class="nav-item">
75 <span class="navbar-text"> 75 <span class="navbar-text">
76 <i class="fas fa-user" aria-hidden="true"></i> 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 <span class="caret"></span> 79 <span class="caret"></span>
80 </span> 80 </span>
81 </li> 81 </li>
@@ -93,11 +93,11 @@ @@ -93,11 +93,11 @@
93 93
94 <div class="row"> 94 <div class="row">
95 <label for="nome" class="col-sm-3">Nome:</label> 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 </div> 97 </div>
98 <div class="row"> 98 <div class="row">
99 <label for="numero" class="col-sm-3">Número:</label> 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 </div> 101 </div>
102 102
103 <div class="row"> 103 <div class="row">
@@ -119,7 +119,9 @@ @@ -119,7 +119,9 @@
119 119
120 <div class="form-row"> 120 <div class="form-row">
121 <div class="col-12"> 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 </div> 125 </div>
124 </div> 126 </div>
125 </form> 127 </form>
@@ -138,11 +140,16 @@ @@ -138,11 +140,16 @@
138 <div class="modal-body"> 140 <div class="modal-body">
139 O teste será enviado para classificação e já não poderá voltar atrás. 141 O teste será enviado para classificação e já não poderá voltar atrás.
140 Antes de submeter, verifique se respondeu a todas as questões. 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 </div> 145 </div>
143 <div class="modal-footer"> 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 </div> 153 </div>
147 </div> 154 </div>
148 </div> 155 </div>
perguntations/test.py
@@ -7,7 +7,6 @@ from datetime import datetime @@ -7,7 +7,6 @@ from datetime import datetime
7 import json 7 import json
8 import logging 8 import logging
9 from math import nan 9 from math import nan
10 -from os import path  
11 10
12 # Logger configuration 11 # Logger configuration
13 logger = logging.getLogger(__name__) 12 logger = logging.getLogger(__name__)
@@ -26,11 +25,11 @@ class Test(dict): @@ -26,11 +25,11 @@ class Test(dict):
26 self['comment'] = '' 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 self['start_time'] = datetime.now() 33 self['start_time'] = datetime.now()
35 self['finish_time'] = None 34 self['finish_time'] = None
36 self['state'] = 'ACTIVE' 35 self['state'] = 'ACTIVE'
perguntations/testfactory.py
@@ -47,15 +47,15 @@ class TestFactory(dict): @@ -47,15 +47,15 @@ class TestFactory(dict):
47 'duration': 0, # 0=infinite 47 'duration': 0, # 0=infinite
48 'autosubmit': False, 48 'autosubmit': False,
49 'autocorrect': True, 49 'autocorrect': True,
50 - 'debug': False, 50 + 'debug': False, # FIXME not property of a test...
51 'show_ref': False, 51 'show_ref': False,
52 }) 52 })
53 self.update(conf) 53 self.update(conf)
54 54
55 # --- for review, we are done. no factories needed 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 # --- perform sanity checks and normalize the test questions 60 # --- perform sanity checks and normalize the test questions
61 self.sanity_checks() 61 self.sanity_checks()
perguntations/tools.py
@@ -19,28 +19,11 @@ import yaml @@ -19,28 +19,11 @@ import yaml
19 logger = logging.getLogger(__name__) 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 def run_script(script: str, 29 def run_script(script: str,
@@ -28,7 +28,9 @@ setup( @@ -28,7 +28,9 @@ setup(
28 'mistune', 28 'mistune',
29 'pyyaml>=5.1', 29 'pyyaml>=5.1',
30 'pygments', 30 'pygments',
31 - 'sqlalchemy', 31 + # 'sqlalchemy',
  32 + 'sqlalchemy[asyncio]',
  33 + 'aiosqlite',
32 'bcrypt>=3.1' 34 'bcrypt>=3.1'
33 ], 35 ],
34 entry_points={ 36 entry_points={