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 @@
  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,
... ...
setup.py
... ... @@ -28,7 +28,9 @@ setup(
28 28 'mistune',
29 29 'pyyaml>=5.1',
30 30 'pygments',
31   - 'sqlalchemy',
  31 + # 'sqlalchemy',
  32 + 'sqlalchemy[asyncio]',
  33 + 'aiosqlite',
32 34 'bcrypt>=3.1'
33 35 ],
34 36 entry_points={
... ...