From 7ce13d3942fec48f3be503b1feef077a4a024027 Mon Sep 17 00:00:00 2001 From: Miguel Barão Date: Mon, 27 Dec 2021 19:11:06 +0000 Subject: [PATCH] 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 | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ demo/questions/generators/generate-question.py | 77 ----------------------------------------------------------------------------- demo/questions/questions-tutorial.yaml | 2 +- perguntations/app.py | 792 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ perguntations/main.py | 83 +++++++++++++++++++++++++++++++++++++++-------------------------------------------- perguntations/questions.py | 2 +- perguntations/serve.py | 195 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------------------------------------------------------------------------------------------------------------------------- perguntations/templates/admin.html | 2 +- perguntations/templates/grade.html | 4 ++-- perguntations/templates/review.html | 4 ++-- perguntations/templates/test.html | 23 +++++++++++++++-------- perguntations/test.py | 7 +++---- perguntations/testfactory.py | 8 ++++---- perguntations/tools.py | 27 +++++---------------------- setup.py | 4 +++- 15 files changed, 575 insertions(+), 732 deletions(-) create mode 100755 demo/questions/generate-question.py delete mode 100755 demo/questions/generators/generate-question.py diff --git a/demo/questions/generate-question.py b/demo/questions/generate-question.py new file mode 100755 index 0000000..92c3275 --- /dev/null +++ b/demo/questions/generate-question.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 + +''' +Example of a question generator. +Arguments are read from stdin. +''' + +from random import randint +import sys + +# read two arguments from the field `args` specified in the question yaml file +a, b = (int(n) for n in sys.argv[1:]) + +x = randint(a, b) +y = randint(a, b) +r = x + y + +print(f"""--- +type: text +title: Geradores de perguntas +text: | + + As perguntas podem ser estáticas (como as que vimos até aqui), ou serem + geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o + programa deve escrever texto no `stdout` em formato `yaml` tal como os + exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode + também receber argumentos de linha de comando para parametrizar a pergunta. + Aqui está um exemplo de uma pergunta gerada por um script python: + + ```python + #!/usr/bin/env python3 + + from random import randint + import sys + + a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando + + x = randint(a, b) # número inteiro no intervalo a..b + y = randint(a, b) # número inteiro no intervalo a..b + r = x + y # calcula resultado correcto + + print(f'''--- + type: text + title: Contas de somar + text: | + Calcule o resultado de ${{x}} + {{y}}$. + correct: '{{r}}' + solution: | + A solução é {{r}}.''') + ``` + + Este script deve ter permissões para poder ser executado no terminal. + Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que + o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar + que este script deve ser usado para gerar uma pergunta. + + Uma pergunta gerada por um programa externo é declarada com + + ```yaml + - type: generator + ref: gen-somar + script: gen-somar.py + # argumentos opcionais + args: [1, 100] + ``` + + O programa pode receber uma lista de argumentos de linha de comando + declarados em `args`. + + --- + + Calcule o resultado de ${x} + {y}$. + + Os números foram gerados aleatoriamente no intervalo de {a} a {b}. +correct: '{r}' +solution: | + A solução é {r}.""") diff --git a/demo/questions/generators/generate-question.py b/demo/questions/generators/generate-question.py deleted file mode 100755 index 92c3275..0000000 --- a/demo/questions/generators/generate-question.py +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env python3 - -''' -Example of a question generator. -Arguments are read from stdin. -''' - -from random import randint -import sys - -# read two arguments from the field `args` specified in the question yaml file -a, b = (int(n) for n in sys.argv[1:]) - -x = randint(a, b) -y = randint(a, b) -r = x + y - -print(f"""--- -type: text -title: Geradores de perguntas -text: | - - As perguntas podem ser estáticas (como as que vimos até aqui), ou serem - geradas dinâmicamente por um programa externo. Para gerar uma pergunta, o - programa deve escrever texto no `stdout` em formato `yaml` tal como os - exemplos das perguntas estáticas dos tipos apresentados anteriormente. Pode - também receber argumentos de linha de comando para parametrizar a pergunta. - Aqui está um exemplo de uma pergunta gerada por um script python: - - ```python - #!/usr/bin/env python3 - - from random import randint - import sys - - a, b = (int(n) for n in sys.argv[1:]) # argumentos da linha de comando - - x = randint(a, b) # número inteiro no intervalo a..b - y = randint(a, b) # número inteiro no intervalo a..b - r = x + y # calcula resultado correcto - - print(f'''--- - type: text - title: Contas de somar - text: | - Calcule o resultado de ${{x}} + {{y}}$. - correct: '{{r}}' - solution: | - A solução é {{r}}.''') - ``` - - Este script deve ter permissões para poder ser executado no terminal. - Podemos testar o programa no terminal `./gen-somar.py 1 100` e verificar que - o output é uma pergunta válida em formato `yaml`. Agora é necessário indicar - que este script deve ser usado para gerar uma pergunta. - - Uma pergunta gerada por um programa externo é declarada com - - ```yaml - - type: generator - ref: gen-somar - script: gen-somar.py - # argumentos opcionais - args: [1, 100] - ``` - - O programa pode receber uma lista de argumentos de linha de comando - declarados em `args`. - - --- - - Calcule o resultado de ${x} + {y}$. - - Os números foram gerados aleatoriamente no intervalo de {a} a {b}. -correct: '{r}' -solution: | - A solução é {r}.""") diff --git a/demo/questions/questions-tutorial.yaml b/demo/questions/questions-tutorial.yaml index a2bfbbc..e6292e8 100644 --- a/demo/questions/questions-tutorial.yaml +++ b/demo/questions/questions-tutorial.yaml @@ -586,7 +586,7 @@ # ---------------------------------------------------------------------------- - type: generator ref: tut-generator - script: generators/generate-question.py + script: generate-question.py args: [1, 100] # ---------------------------------------------------------------------------- diff --git a/perguntations/app.py b/perguntations/app.py index 315184b..7808388 100644 --- a/perguntations/app.py +++ b/perguntations/app.py @@ -8,49 +8,41 @@ Description: Main application logic. import asyncio import csv import io -import json import logging -from os import path +import os +from typing import Optional # installed packages import bcrypt -from sqlalchemy import create_engine, select, func +from sqlalchemy import create_engine, select +from sqlalchemy.exc import OperationalError, NoResultFound, IntegrityError from sqlalchemy.orm import Session -# from sqlalchemy.exc import NoResultFound +import yaml # this project from perguntations.models import Student, Test, Question -from perguntations.questions import question_from from perguntations.tools import load_yaml from perguntations.testfactory import TestFactory, TestFactoryException -import perguntations.test # setup logger for this module logger = logging.getLogger(__name__) -# ============================================================================ -class AppException(Exception): - '''Exception raised in this module''' +async def check_password(password: str, hashed: bytes) -> bool: + '''check password in executor''' + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, bcrypt.checkpw, + password.encode('utf-8'), hashed) +async def hash_password(password: str) -> bytes: + '''get hash for password''' + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, bcrypt.hashpw, + password.encode('utf-8'), bcrypt.gensalt()) # ============================================================================ -# helper functions -# ============================================================================ -# async def check_password(try_pw, hashed_pw): -# '''check password in executor''' -# try_pw = try_pw.encode('utf-8') -# loop = asyncio.get_running_loop() -# hashed = await loop.run_in_executor(None, bcrypt.hashpw, try_pw, hashed_pw) -# return hashed_pw == hashed - - -# async def hash_password(password): -# '''hash password in executor''' -# loop = asyncio.get_running_loop() -# return await loop.run_in_executor(None, bcrypt.hashpw, -# password.encode('utf-8'), -# bcrypt.gensalt()) +class AppException(Exception): + '''Exception raised in this module''' # ============================================================================ @@ -58,279 +50,154 @@ class AppException(Exception): # ============================================================================ class App(): ''' - This is the main application - state: - self.Session - self.online - {uid: - {'student':{...}, 'test': {...}}, - ... - } - self.allowd - {'123', '124', ...} - self.testfactory - TestFactory + Main application ''' - # # ------------------------------------------------------------------------ - # @contextmanager - # def _db_session(self): - # ''' - # helper to manage db sessions using the `with` statement, for example: - # with self._db_session() as s: s.query(...) - # ''' - # session = self.Session() - # try: - # yield session - # session.commit() - # except exc.SQLAlchemyError: - # logger.error('DB rollback!!!') - # session.rollback() - # raise - # finally: - # session.close() - # ------------------------------------------------------------------------ - def __init__(self, conf): - self.online = dict() # {uid: {'student':{...}, 'test': {...}}, ...} - self.allowed = set() # '0' is hardcoded to allowed elsewhere - self.unfocus = set() # set of students that have no browser focus - self.area = dict() # {uid: percent_area} - self.pregenerated_tests = [] # list of tests to give to students - - self._make_test_factory(conf) - self._db_setup() - - # command line option --allow-all - if conf['allow_all']: + def __init__(self, config): + self._make_test_factory(config['testfile']) + self._db_setup() # setup engine and load all students + + # command line options: --allow-all, --allow-list filename + if config['allow_all']: self.allow_all_students() - elif conf['allow_list'] is not None: - self.allow_list(conf['allow_list']) + elif config['allow_list'] is not None: + self.allow_from_list(config['allow_list']) else: - logger.info('Students not yet allowed to login.') - - # pre-generate tests for allowed students - if self.allowed: - logger.info('Generating %d tests. May take awhile...', - len(self.allowed)) - self._pregenerate_tests(len(self.allowed)) + logger.info('Students login not yet allowed') - if conf['correct']: - self._correct_tests() + # if config['correct']: + # self._correct_tests() # ------------------------------------------------------------------------ def _db_setup(self) -> None: - logger.info('Setup database') + logger.debug('Checking database...') # connect to database and check registered students - dbfile = path.expanduser(self.testfactory['database']) - if not path.exists(dbfile): - raise AppException('Database does not exist. Use "initdb" to create.') + dbfile = os.path.expanduser(self._testfactory['database']) + if not os.path.exists(dbfile): + raise AppException('No database. Use "initdb" to create.') self._engine = create_engine(f'sqlite:///{dbfile}', future=True) - try: with Session(self._engine, future=True) as session: - num = session.execute( - select(func.count(Student.id)).where(Student.id != '0') - ).scalar() - except Exception as exc: - raise AppException(f'Database unusable {dbfile}.') from exc - - logger.info('Database "%s" has %s students.', dbfile, num) - -# # ------------------------------------------------------------------------ -# # FIXME not working -# def _correct_tests(self): -# with Session(self._engine, future=True) as session: -# # Find which tests have to be corrected -# dbtests = session.execute( -# select(Test). -# where(Test.ref == self.testfactory['ref']). -# where(Test.state == "SUBMITTED") -# ).all() -# # dbtests = session.query(Test)\ -# # .filter(Test.ref == self.testfactory['ref'])\ -# # .filter(Test.state == "SUBMITTED")\ -# # .all() + query = select(Student.id, Student.name)\ + .where(Student.id != '0') + dbstudents = session.execute(query).all() + session.execute(select(Student).where(Student.id == '0')).one() + except NoResultFound: + msg = 'Database has no administrator (user "0")' + logger.error(msg) + raise AppException(msg) from None + except OperationalError: + msg = f'Database "{dbfile}" unusable.' + logger.error(msg) + raise AppException(msg) from None -# logger.info('Correcting %d tests...', len(dbtests)) -# for dbtest in dbtests: -# try: -# with open(dbtest.filename) as file: -# testdict = json.load(file) -# except FileNotFoundError: -# logger.error('File not found: %s', dbtest.filename) -# continue + logger.info('Database "%s" has %d students.', dbfile, len(dbstudents)) -# # creates a class Test with the methods to correct it -# # the questions are still dictionaries, so we have to call -# # question_from() to produce Question() instances that can be -# # corrected. Finally the test can be corrected. -# test = perguntations.test.Test(testdict) -# test['questions'] = [question_from(q) for q in test['questions']] -# test.correct() -# logger.info('Student %s: grade = %f', test['student']['number'], test['grade']) - -# # save JSON file (overwriting the old one) -# uid = test['student']['number'] -# ref = test['ref'] -# finish_time = test['finish_time'] -# answers_dir = test['answers_dir'] -# fname = f'{uid}--{ref}--{finish_time}.json' -# fpath = path.join(answers_dir, fname) -# test.save_json(fpath) -# logger.info('%s saved JSON file.', uid) + self._students = {uid: {'name': name, 'state': 'offline', 'test': None} + for uid, name in dbstudents} -# # update database -# dbtest.grade = test['grade'] -# dbtest.state = test['state'] -# dbtest.questions = [ -# Question( -# number=n, -# ref=q['ref'], -# grade=q['grade'], -# comment=q.get('comment', ''), -# starttime=str(test['start_time']), -# finishtime=str(test['finish_time']), -# test_id=test['ref'] -# ) -# for n, q in enumerate(test['questions']) -# ] -# logger.info('%s database updated.', uid) + # self._students = {} + # for uid, name in dbstudents: + # self._students[uid] = { + # 'name': name, + # 'state': 'offline', # offline, allowed, waiting, online + # 'test': None + # } # ------------------------------------------------------------------------ - async def login(self, uid, password, headers=None): - '''login authentication''' - if uid != '0' and uid not in self.allowed: # not allowed - logger.warning('"%s" unauthorized.', uid) - return 'unauthorized' - - with Session(self._engine, future=True) as session: - name, hashed = session.execute( - select(Student.name, Student.password). - where(Student.id == uid) - ).one() - - if hashed == '': # update password on first login - logger.info('First login "%s"', name) - await self.update_password(uid, password) - ok = True - else: # check password - loop = asyncio.get_running_loop() - ok = await loop.run_in_executor(None, - bcrypt.checkpw, - password.encode('utf-8'), - hashed) - - if not ok: - logger.info('"%s" wrong password.', uid) + async def login(self, uid: str, password: str, headers: dict) -> Optional[str]: + ''' + Login authentication + If successful returns None, else returns an error message + ''' + try: + with Session(self._engine, future=True) as session: + query = select(Student.password).where(Student.id == uid) + hashed = session.execute(query).scalar_one() + except NoResultFound: + logger.warning('"%s" does not exist', uid) + return 'nonexistent' + + if uid != '0' and self._students[uid]['state'] != 'allowed': + logger.warning('"%s" login not allowed', uid) + return 'not allowed' + + if hashed == '': # set password on first login + await self.set_password(uid, password) + elif not await check_password(password, hashed): + logger.info('"%s" wrong password', uid) return 'wrong_password' # success - self.allowed.discard(uid) # remove from set of allowed students + if uid == '0': + logger.info('Admin login from %s', headers['remote_ip']) + return - if uid in self.online: - logger.warning('"%s" login again from %s (reusing state).', - uid, headers['remote_ip']) - # FIXME invalidate previous login - else: - # first login - self.online[uid] = {'student': { - 'name': name, - 'number': uid, - 'headers': headers}} - logger.info('"%s" login from %s.', uid, headers['remote_ip']) + # FIXME this should probably be done elsewhere + test = await self._testfactory.generate() + test.start(uid) + self._students[uid]['test'] = test + + self._students[uid]['state'] = 'waiting' + self._students[uid]['headers'] = headers + logger.info('"%s" login from %s.', uid, headers['remote_ip']) + + # ------------------------------------------------------------------------ + async def set_password(self, uid: str, password: str) -> None: + '''change password on the database''' + with Session(self._engine, future=True) as session: + query = select(Student).where(Student.id == uid) + student = session.execute(query).scalar_one() + student.password = await hash_password(password) if password else '' + session.commit() + logger.info('"%s" password updated', uid) # ------------------------------------------------------------------------ - def logout(self, uid): + def logout(self, uid: str) -> None: '''student logout''' - self.online.pop(uid, None) # remove from dict if exists + if uid in self._students: + self._students[uid]['test'] = None + self._students[uid]['state'] = 'offline' logger.info('"%s" logged out.', uid) # ------------------------------------------------------------------------ - def _make_test_factory(self, conf): + def _make_test_factory(self, filename: str) -> None: ''' Setup a factory for the test ''' # load configuration from yaml file - logger.info('Loading test configuration "%s".', conf["testfile"]) try: - testconf = load_yaml(conf['testfile']) - except Exception as exc: - msg = 'Error loading test configuration YAML.' - logger.critical(msg) + testconf = load_yaml(filename) + testconf['testfile'] = filename + except (IOError, yaml.YAMLError) as exc: + msg = f'Cannot read test configuration "{filename}"' + logger.error(msg) raise AppException(msg) from exc - # command line options override configuration - testconf.update(conf) - - # start test factory + # make test factory logger.info('Running test factory...') try: - self.testfactory = TestFactory(testconf) + self._testfactory = TestFactory(testconf) except TestFactoryException as exc: - logger.critical(exc) + logger.error(exc) raise AppException('Failed to create test factory!') from exc # ------------------------------------------------------------------------ - def _pregenerate_tests(self, num): # TODO needs improvement - event_loop = asyncio.get_event_loop() - self.pregenerated_tests += [ - event_loop.run_until_complete(self.testfactory.generate()) - for _ in range(num)] - - # ------------------------------------------------------------------------ - async def get_test_or_generate(self, uid): - '''get current test or generate a new one''' - try: - student = self.online[uid] - except KeyError as exc: - msg = f'"{uid}" is not online. get_test_or_generate() FAILED' - logger.error(msg) - raise AppException(msg) from exc - - # get current test. if test does not exist then generate a new one - if not 'test' in student: - await self._new_test(uid) - - return student['test'] - - def get_test(self, uid): - '''get test from online student or raise exception''' - return self.online[uid]['test'] - - # ------------------------------------------------------------------------ - async def _new_test(self, uid): - ''' - assign a test to a given student. if there are pregenerated tests then - use one of them, otherwise generate one. - the student must be online - ''' - student = self.online[uid]['student'] # {'name': ?, 'number': ?} - - try: - test = self.pregenerated_tests.pop() - except IndexError: - logger.info('"%s" generating new test...', uid) - test = await self.testfactory.generate() - logger.info('"%s" test is ready.', uid) - else: - logger.info('"%s" using a pregenerated test.', uid) - - test.start(student) # student signs the test - self.online[uid]['test'] = test - - # ------------------------------------------------------------------------ - async def submit_test(self, uid, ans): + async def submit_test(self, uid, ans) -> None: ''' Handles test submission and correction. ans is a dictionary {question_index: answer, ...} with the answers for the complete test. For example: {0:'hello', 1:[1,2]} ''' - test = self.online[uid]['test'] + logger.info('"%s" submitted %d answers.', uid, len(ans)) # --- submit answers and correct test + test = self._students[uid]['test'] test.submit(ans) - logger.info('"%s" submitted %d answers.', uid, len(ans)) if test['autocorrect']: await test.correct_async() @@ -338,7 +205,7 @@ class App(): # --- save test in JSON format fname = f'{uid}--{test["ref"]}--{test["finish_time"]}.json' - fpath = path.join(test['answers_dir'], fname) + fpath = os.path.join(test['answers_dir'], fname) test.save_json(fpath) logger.info('"%s" saved JSON.', uid) @@ -374,9 +241,66 @@ class App(): session.commit() logger.info('"%s" database updated.', uid) - # ------------------------------------------------------------------------ - def get_student_grade(self, uid): - return self.online[uid]['test'].get('grade', None) +# # ------------------------------------------------------------------------ + # FIXME not working +# def _correct_tests(self): +# with Session(self._engine, future=True) as session: +# # Find which tests have to be corrected +# dbtests = session.execute( +# select(Test). +# where(Test.ref == self.testfactory['ref']). +# where(Test.state == "SUBMITTED") +# ).all() +# # dbtests = session.query(Test)\ +# # .filter(Test.ref == self.testfactory['ref'])\ +# # .filter(Test.state == "SUBMITTED")\ +# # .all() + +# logger.info('Correcting %d tests...', len(dbtests)) +# for dbtest in dbtests: +# try: +# with open(dbtest.filename) as file: +# testdict = json.load(file) +# except FileNotFoundError: +# logger.error('File not found: %s', dbtest.filename) +# continue + +# # creates a class Test with the methods to correct it +# # the questions are still dictionaries, so we have to call +# # question_from() to produce Question() instances that can be +# # corrected. Finally the test can be corrected. +# test = perguntations.test.Test(testdict) +# test['questions'] = [question_from(q) for q in test['questions']] +# test.correct() +# logger.info('Student %s: grade = %f', test['student']['number'], test['grade']) + +# # save JSON file (overwriting the old one) +# uid = test['student']['number'] +# ref = test['ref'] +# finish_time = test['finish_time'] +# answers_dir = test['answers_dir'] +# fname = f'{uid}--{ref}--{finish_time}.json' +# fpath = os.path.join(answers_dir, fname) +# test.save_json(fpath) +# logger.info('%s saved JSON file.', uid) + +# # update database +# dbtest.grade = test['grade'] +# dbtest.state = test['state'] +# dbtest.questions = [ +# Question( +# number=n, +# ref=q['ref'], +# grade=q['grade'], +# comment=q.get('comment', ''), +# starttime=str(test['start_time']), +# finishtime=str(test['finish_time']), +# test_id=test['ref'] +# ) +# for n, q in enumerate(test['questions']) +# ] +# logger.info('%s database updated.', uid) + # ------------------------------------------------------------------------ # def giveup_test(self, uid): @@ -388,7 +312,7 @@ class App(): # fields = (test['student']['number'], test['ref'], # str(test['finish_time'])) # fname = '--'.join(fields) + '.json' - # fpath = path.join(test['answers_dir'], fname) + # fpath = os.path.join(test['answers_dir'], fname) # test.save_json(fpath) # # insert test into database @@ -409,137 +333,143 @@ class App(): # ------------------------------------------------------------------------ def event_test(self, uid, cmd, value): '''handles browser events the occur during the test''' - if cmd == 'focus': - if value: - self._focus_student(uid) - else: - self._unfocus_student(uid) - elif cmd == 'size': - self._set_screen_area(uid, value) + # if cmd == 'focus': + # if value: + # self._focus_student(uid) + # else: + # self._unfocus_student(uid) + # elif cmd == 'size': + # self._set_screen_area(uid, value) + + # ======================================================================== + # GETTERS + # ======================================================================== + def get_test(self, uid: str) -> Optional[dict]: + '''return student test''' + return self._students[uid]['test'] # ------------------------------------------------------------------------ - # --- GETTERS - # ------------------------------------------------------------------------ + def get_name(self, uid: str) -> str: + '''return name of student''' + return self._students[uid]['name'] - # def get_student_name(self, uid): - # return self.online[uid]['student']['name'] + # ------------------------------------------------------------------------ + def get_test_config(self) -> dict: + '''return brief test configuration''' + return {'title': self._testfactory['title'], + 'ref': self._testfactory['ref'], + 'filename': self._testfactory['testfile'], + 'database': self._testfactory['database'], + 'answers_dir': self._testfactory['answers_dir'] + } - def get_questions_csv(self): - '''generates a CSV with the grades of the test''' - test_ref = self.testfactory['ref'] + # ------------------------------------------------------------------------ + def get_test_csv(self): + '''generates a CSV with the grades of the test currently running''' + test_ref = self._testfactory['ref'] with Session(self._engine, future=True) as session: - questions = session.execute( - select(Test.id, Test.student_id, Test.starttime, - Question.number, Question.grade). - join(Question). - where(Test.ref == test_ref) - ).all() - print(questions) - - - - # questions = sess.query(Test.id, Test.student_id, Test.starttime, - # Question.number, Question.grade)\ - # .join(Question)\ - # .filter(Test.ref == test_ref)\ - # .all() - - qnums = set() # keeps track of all the questions in the test - tests = {} # {test_id: {student_id, starttime, 0: grade, ...}} - for question in questions: - test_id, student_id, starttime, num, grade = question - default_test_id = {'Aluno': student_id, 'Início': starttime} - tests.setdefault(test_id, default_test_id)[num] = grade - qnums.add(num) - + query = select(Test.student_id, Test.grade, + Test.starttime, Test.finishtime)\ + .where(Test.ref == test_ref)\ + .order_by(Test.student_id) + tests = session.execute(query).all() if not tests: logger.warning('Empty CSV: there are no tests!') return test_ref, '' - cols = ['Aluno', 'Início'] + list(qnums) - csvstr = io.StringIO() - writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, - delimiter=';', quoting=csv.QUOTE_ALL) - writer.writeheader() - writer.writerows(tests.values()) + writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) + writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) + writer.writerows(tests) return test_ref, csvstr.getvalue() - def get_test_csv(self): - '''generates a CSV with the grades of the test currently running''' - test_ref = self.testfactory['ref'] + # ------------------------------------------------------------------------ + def get_detailed_grades_csv(self): + '''generates a CSV with the grades of the test''' + test_ref = self._testfactory['ref'] with Session(self._engine, future=True) as session: - tests = session.execute( - select(Test.student_id, Test.grade, Test.starttime, Test.finishtime). - where(Test.ref == test_ref). - order_by(Test.student_id) - ).all() - # with self._db_session() as sess: - # tests = sess.query(Test.student_id, - # Test.grade, - # Test.starttime, Test.finishtime)\ - # .filter(Test.ref == test_ref)\ - # .order_by(Test.student_id)\ - # .all() - - print(tests) + query = select(Test.id, Test.student_id, Test.starttime, + Question.number, Question.grade)\ + .join(Question)\ + .where(Test.ref == test_ref) + questions = session.execute(query).all() + + cols = ['Aluno', 'Início'] + tests = {} # {test_id: {student_id, starttime, 0: grade, ...}} + for test_id, student_id, starttime, num, grade in questions: + default_test_id = {'Aluno': student_id, 'Início': starttime} + tests.setdefault(test_id, default_test_id)[num] = grade + if num not in cols: + cols.append(num) + if not tests: logger.warning('Empty CSV: there are no tests!') return test_ref, '' csvstr = io.StringIO() - writer = csv.writer(csvstr, delimiter=';', quoting=csv.QUOTE_ALL) - writer.writerow(('Aluno', 'Nota', 'Início', 'Fim')) - writer.writerows(tests) - + writer = csv.DictWriter(csvstr, fieldnames=cols, restval=None, + delimiter=';', quoting=csv.QUOTE_ALL) + writer.writeheader() + writer.writerows(tests.values()) return test_ref, csvstr.getvalue() # ------------------------------------------------------------------------ - def get_student_grades_from_all_tests(self, uid): - '''get grades of student from all tests''' - with self._db_session() as sess: - return sess.query(Test.title, Test.grade, Test.finishtime)\ - .filter_by(student_id=uid)\ - .order_by(Test.finishtime) - def get_json_filename_of_test(self, test_id): '''get JSON filename from database given the test_id''' - with self._db_session() as sess: - return sess.query(Test.filename)\ - .filter_by(id=test_id)\ - .scalar() + with Session(self._engine, future=True) as session: + query = select(Test.filename).where(Test.id == test_id) + return session.execute(query).scalar() - def get_student_grades_from_test(self, uid, testid): + # ------------------------------------------------------------------------ + def get_grades(self, uid, ref): '''get grades of student for a given testid''' - with self._db_session() as sess: - return sess.query(Test.grade, Test.finishtime, Test.id)\ - .filter_by(student_id=uid)\ - .filter_by(ref=testid)\ - .all() + with Session(self._engine, future=True) as session: + query = select(Test.grade, Test.finishtime, Test.id)\ + .where(Test.student_id == uid)\ + .where(Test.ref == ref) + grades = session.execute(query).all() + return [tuple(grade) for grade in grades] - def get_students_state(self): + # ------------------------------------------------------------------------ + def get_students_state(self) -> list: '''get list of states of every student''' - return [{ - 'uid': uid, - 'name': name, - 'allowed': uid in self.allowed, - 'online': uid in self.online, - 'start_time': self.online.get(uid, {}).get('test', {}) - .get('start_time', ''), - 'password_defined': pw != '', - 'unfocus': uid in self.unfocus, - 'area': self.area.get(uid, None), - 'grades': self.get_student_grades_from_test( - uid, self.testfactory['ref']) - } for uid, name, pw in self._get_all_students()] + return [{ 'uid': uid, + 'name': student['name'], + 'allowed': student['state'] == 'allowed', + 'online': student['state'] == 'online', + # 'start_time': student.get('test', {}).get('start_time', ''), + # 'password_defined': False, #pw != '', + # 'unfocus': False, + # 'area': '0.89', + 'grades': self.get_grades(uid, self._testfactory['ref']) } + for uid, student in self._students.items()] + + # ------------------------------------------------------------------------ + # def get_student_grades_from_all_tests(self, uid): + # '''get grades of student from all tests''' + # with self._db_session() as sess: + # return sess.query(Test.title, Test.grade, Test.finishtime)\ + # .filter_by(student_id=uid)\ + # .order_by(Test.finishtime) # --- private methods ---------------------------------------------------- - def _get_all_students(self): - '''get all students from database''' - with self._db_session() as sess: - return sess.query(Student.id, Student.name, Student.password)\ - .filter(Student.id != '0')\ - .order_by(Student.id) + # def _get_all_students(self): + # '''get all students from database''' + # with Session(self._engine, future=True) as session: + # query = select(Student.id, Student.name, Student.password)\ + # .where(Student.id != '0') + # students + # questions = session.execute( + # select(Test.id, Test.student_id, Test.starttime, + # Question.number, Question.grade). + # join(Question). + # where(Test.ref == test_ref) + # ).all() + + + # return session.query(Student.id, Student.name, Student.password)\ + # .filter(Student.id != '0')\ + # .order_by(Student.id) # def get_allowed_students(self): # # set of 'uid' allowed to login @@ -550,105 +480,91 @@ class App(): # t = self.get_student_test(uid) # for q in t['questions']: # if q['ref'] == ref and key in q['files']: - # return path.abspath(path.join(q['path'], q['files'][key])) - - # ------------------------------------------------------------------------ - # --- SETTERS - # ------------------------------------------------------------------------ + # return os.path.abspath(os.path.join(q['path'], q['files'][key])) - def allow_student(self, uid): + # ======================================================================== + # SETTERS + # ======================================================================== + def allow_student(self, uid: str) -> None: '''allow a single student to login''' - self.allowed.add(uid) + self._students[uid]['state'] = 'allowed' logger.info('"%s" allowed to login.', uid) - def deny_student(self, uid): + # ------------------------------------------------------------------------ + def deny_student(self, uid: str) -> None: '''deny a single student to login''' - self.allowed.discard(uid) + student = self._students[uid] + if student['state'] == 'allowed': + student['state'] = 'offline' logger.info('"%s" denied to login', uid) - def allow_all_students(self): + # ------------------------------------------------------------------------ + def allow_all_students(self) -> None: '''allow all students to login''' - all_students = self._get_all_students() - self.allowed.update(s[0] for s in all_students) - logger.info('Allowed all %d students.', len(self.allowed)) + for student in self._students.values(): + student['state'] = 'allowed' + logger.info('Allowed %d students.', len(self._students)) - def deny_all_students(self): + # ------------------------------------------------------------------------ + def deny_all_students(self) -> None: '''deny all students to login''' logger.info('Denying all students...') - self.allowed.clear() + for student in self._students.values(): + if student['state'] == 'allowed': + student['state'] = 'offline' + + # ------------------------------------------------------------------------ + def insert_new_student(self, uid: str, name: str) -> None: + '''insert new student into the database''' + with Session(self._engine, future=True) as session: + try: + session.add(Student(id=uid, name=name, password='')) + session.commit() + except IntegrityError: + logger.warning('"%s" already exists!', uid) + session.rollback() + return + logger.info('New student added: %s %s', uid, name) + self._students[uid] = {'name': name, 'state': 'offline', 'test': None} - def allow_list(self, filename): - '''allow students listed in file (one number per line)''' + # ------------------------------------------------------------------------ + def allow_from_list(self, filename: str) -> None: + '''allow students listed in text file (one number per line)''' try: - with open(filename, 'r') as file: - allowed_in_file = {s.strip() for s in file} - {''} - except Exception as exc: + with open(filename, 'r', encoding='utf-8') as file: + allowed = {line.strip() for line in file} + allowed.discard('') + except IOError as exc: error_msg = f'Cannot read file {filename}' logger.critical(error_msg) raise AppException(error_msg) from exc - enrolled = set(s[0] for s in self._get_all_students()) # in database - self.allowed.update(allowed_in_file & enrolled) - logger.info('Allowed %d students provided in "%s"', len(self.allowed), - filename) - - not_enrolled = allowed_in_file - enrolled - if not_enrolled: - logger.warning(' but found students not in the database: %s', - ', '.join(not_enrolled)) - - def _focus_student(self, uid): - '''set student in focus state''' - self.unfocus.discard(uid) - logger.info('"%s" focus', uid) - - def _unfocus_student(self, uid): - '''set student in unfocus state''' - self.unfocus.add(uid) - logger.info('"%s" unfocus', uid) - - def _set_screen_area(self, uid, sizes): - '''set current browser area as detected in resize event''' - scr_y, scr_x, win_y, win_x = sizes - area = win_x * win_y / (scr_x * scr_y) * 100 - self.area[uid] = area - logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', - uid, area, win_x, win_y, scr_x, scr_y) - - async def update_password(self, uid, password=''): - '''change password on the database''' - if password: - # password = await hash_password(password) - loop = asyncio.get_running_loop() - password = await loop.run_in_executor(None, - bcrypt.hashpw, - password.encode('utf-8'), - bcrypt.gensalt()) - - # with self._db_session() as sess: - # student = sess.query(Student).filter_by(id=uid).one() - with Session(self._engine, future=True) as session: - student = session.execute( - select(Student). - where(Student.id == uid) - ).scalar_one() - student.password = password - logger.info('"%s" password updated.', uid) - - def insert_new_student(self, uid, name): - '''insert new student into the database''' - with Session(self._engine, future=True) as session: - session.add( - Student(id=uid, name=name, password='') - ) - session.commit() - # try: - # with Session(self._engine, future=True) as session: - # session.add( - # Student(id=uid, name=name, password='') - # ) - # session.commit() - # except Exception: - # logger.error('Insert failed: student %s already exists?', uid) - # else: - # logger.info('New student: "%s", "%s"', uid, name) + missing = 0 + for uid in allowed: + try: + self.allow_student(uid) + except KeyError: + logger.warning('Allowed student "%s" does not exist!', uid) + missing += 1 + + logger.info('Allowed %d students', len(allowed)-missing) + if missing: + logger.warning(' %d missing!', missing) + + # def _focus_student(self, uid): + # '''set student in focus state''' + # self.unfocus.discard(uid) + # logger.info('"%s" focus', uid) + + # def _unfocus_student(self, uid): + # '''set student in unfocus state''' + # self.unfocus.add(uid) + # logger.info('"%s" unfocus', uid) + + # def _set_screen_area(self, uid, sizes): + # '''set current browser area as detected in resize event''' + # scr_y, scr_x, win_y, win_x = sizes + # area = win_x * win_y / (scr_x * scr_y) * 100 + # self.area[uid] = area + # logger.info('"%s" area=%g%%, window=%dx%d, screen=%dx%d', + # uid, area, win_x, win_y, scr_x, scr_y) diff --git a/perguntations/main.py b/perguntations/main.py index d63a72e..424e2e9 100644 --- a/perguntations/main.py +++ b/perguntations/main.py @@ -10,7 +10,6 @@ import argparse import logging import logging.config import os -from os import environ, path import ssl import sys @@ -62,54 +61,50 @@ def parse_cmdline_arguments(): # ---------------------------------------------------------------------------- -def get_logger_config(debug=False): +def get_logger_config(debug=False) -> dict: ''' Load logger configuration from ~/.config directory if exists, otherwise set default paramenters. ''' + + file = 'logger-debug.yaml' if debug else 'logger.yaml' + path = os.path.expanduser(os.environ.get('XDG_CONFIG_HOME', '~/.config/')) + try: + return load_yaml(os.path.join(path, APP_NAME, file)) + except IOError: + print('Using default logger configuration...') + if debug: - filename = 'logger-debug.yaml' level = 'DEBUG' + # fmt = '%(asctime)s | %(levelname)-8s | %(module)-16s:%(lineno)4d | %(thread)d | %(message)s' + fmt = '%(asctime)s %(levelname)-8s %(module)-12s%(lineno)4d| %(message)s' + dateformat = '' else: - filename = 'logger.yaml' level = 'INFO' - - config_dir = environ.get('XDG_CONFIG_HOME', '~/.config/') - config_file = path.join(path.expanduser(config_dir), APP_NAME, filename) - - default_config = { - 'version': 1, - 'formatters': { - 'standard': { - 'format': '%(asctime)s %(levelname)-8s %(message)s', - 'datefmt': '%H:%M:%S', - }, - }, - 'handlers': { - 'default': { - 'level': level, - 'class': 'logging.StreamHandler', - 'formatter': 'standard', - 'stream': 'ext://sys.stdout', + fmt = '%(asctime)s |%(levelname)-8s| %(message)s' + dateformat = '%Y-%m-%d %H:%M:%S' + modules = ['main', 'serve', 'app', 'models', 'questions', 'test', + 'testfactory', 'tools'] + logger = {'handlers': ['default'], 'level': level, 'propagate': False} + return { + 'version': 1, + 'formatters': { + 'standard': { + 'format': fmt, + 'datefmt': dateformat, + }, }, - }, - 'loggers': { - '': { # configuration for serve.py - 'handlers': ['default'], - 'level': level, + 'handlers': { + 'default': { + 'level': level, + 'class': 'logging.StreamHandler', + 'formatter': 'standard', + 'stream': 'ext://sys.stdout', + }, }, - }, + 'loggers': {f'{APP_NAME}.{module}': logger for module in modules} } - modules = ['app', 'models', 'questions', 'test', 'testfactory', 'tools'] - logger = {'handlers': ['default'], 'level': level, 'propagate': False} - - default_config['loggers'].update({f'{APP_NAME}.{module}': logger - for module in modules}) - - return load_yaml(config_file, default=default_config) - - # ---------------------------------------------------------------------------- def main(): ''' @@ -121,7 +116,7 @@ def main(): logging.config.dictConfig(get_logger_config(args.debug)) logger = logging.getLogger(__name__) - logger.info('====================== Start Logging ======================') + logger.info('================== Start Logging ==================') # --- start application -------------------------------------------------- config = { @@ -137,24 +132,24 @@ def main(): try: app = App(config) except AppException: - logger.critical('Failed to start application.') + logger.critical('Failed to start application!') sys.exit(1) # --- get SSL certificates ----------------------------------------------- if 'XDG_DATA_HOME' in os.environ: - certs_dir = path.join(os.environ['XDG_DATA_HOME'], 'certs') + certs_dir = os.path.join(os.environ['XDG_DATA_HOME'], 'certs') else: - certs_dir = path.expanduser('~/.local/share/certs') + certs_dir = os.path.expanduser('~/.local/share/certs') ssl_opt = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) try: - ssl_opt.load_cert_chain(path.join(certs_dir, 'cert.pem'), - path.join(certs_dir, 'privkey.pem')) + ssl_opt.load_cert_chain(os.path.join(certs_dir, 'cert.pem'), + os.path.join(certs_dir, 'privkey.pem')) except FileNotFoundError: logger.critical('SSL certificates missing in %s', certs_dir) sys.exit(1) - # --- run webserver ---------------------------------------------------- + # --- run webserver ------------------------------------------------------ run_webserver(app=app, ssl_opt=ssl_opt, port=args.port, debug=args.debug) diff --git a/perguntations/questions.py b/perguntations/questions.py index 3678799..25664a6 100644 --- a/perguntations/questions.py +++ b/perguntations/questions.py @@ -363,7 +363,7 @@ class QuestionText(Question): ans = ans.replace(' ', '') elif transform == 'trim': # removes spaces around ans = ans.strip() - elif transform == 'normalize_space': # replaces multiple spaces by one + elif transform == 'normalize_space': # replaces many spaces by one ans = re.sub(r'\s+', ' ', ans.strip()) elif transform == 'lower': # convert to lowercase ans = ans.lower() diff --git a/perguntations/serve.py b/perguntations/serve.py index d943bf2..8f02ecb 100644 --- a/perguntations/serve.py +++ b/perguntations/serve.py @@ -10,7 +10,7 @@ import asyncio import base64 import functools import json -import logging.config +import logging import mimetypes from os import path import re @@ -22,13 +22,16 @@ import uuid # user installed libraries import tornado.ioloop import tornado.web -# import tornado.websocket import tornado.httpserver # this project from perguntations.parser_markdown import md_to_html +# setup logger for this module +logger = logging.getLogger(__name__) + + # ---------------------------------------------------------------------------- class WebApplication(tornado.web.Application): ''' @@ -41,8 +44,6 @@ class WebApplication(tornado.web.Application): (r'/review', ReviewHandler), (r'/admin', AdminHandler), (r'/file', FileHandler), - # (r'/root', MainHandler), - # (r'/ws', AdminSocketHandler), (r'/adminwebservice', AdminWebservice), (r'/studentwebservice', StudentWebservice), (r'/', RootHandler), @@ -64,8 +65,7 @@ class WebApplication(tornado.web.Application): # ---------------------------------------------------------------------------- def admin_only(func): ''' - Decorator used to restrict access to the administrator. - Example: + Decorator to restrict access to the administrator: @admin_only def get(self): ... @@ -104,72 +104,16 @@ class BaseHandler(tornado.web.RequestHandler): # ---------------------------------------------------------------------------- -# class MainHandler(BaseHandler): - -# @tornado.web.authenticated -# @admin_only -# def get(self): -# self.render("admin-ws.html", students=self.testapp.get_students_state()) - - -# # ---------------------------------------------------------------------------- -# class AdminSocketHandler(tornado.websocket.WebSocketHandler): -# waiters = set() -# # cache = [] - -# # def get_compression_options(self): -# # return {} # Non-None enables compression with default options. - -# # called when opening connection -# def open(self): -# logging.debug('[AdminSocketHandler.open]') -# AdminSocketHandler.waiters.add(self) - -# # called when closing connection -# def on_close(self): -# logging.debug('[AdminSocketHandler.on_close]') -# AdminSocketHandler.waiters.remove(self) - -# # @classmethod -# # def update_cache(cls, chat): -# # logging.debug(f'[AdminSocketHandler.update_cache] "{chat}"') -# # cls.cache.append(chat) - -# # @classmethod -# # def send_updates(cls, chat): -# # logging.info("sending message to %d waiters", len(cls.waiters)) -# # for waiter in cls.waiters: -# # try: -# # waiter.write_message(chat) -# # except Exception: -# # logging.error("Error sending message", exc_info=True) - -# # handle incomming messages -# def on_message(self, message): -# logging.info(f"[AdminSocketHandler.onmessage] got message {message}") -# parsed = tornado.escape.json_decode(message) -# print(parsed) -# chat = {"id": str(uuid.uuid4()), "body": parsed["body"]} -# print(chat) -# chat["html"] = tornado.escape.to_basestring( -# '
' + chat['body'] + '
' -# # self.render_string("message.html", message=chat) -# ) -# print(chat) - -# AdminSocketHandler.update_cache(chat) # store msgs -# AdminSocketHandler.send_updates(chat) # send to clients - -# ---------------------------------------------------------------------------- # pylint: disable=abstract-method class LoginHandler(BaseHandler): '''Handles /login''' _prefix = re.compile(r'[a-z]') _error_msg = { - 'wrong_password': 'Password errada', - 'already_online': 'Já está online, não pode entrar duas vezes', - 'unauthorized': 'Não está autorizado a fazer o teste' + 'wrong_password': 'Senha errada', + # 'already_online': 'Já está online, não pode entrar duas vezes', + 'not allowed': 'Não está autorizado a fazer o teste', + 'nonexistent': 'Número de aluno inválido' } def get(self): @@ -178,7 +122,8 @@ class LoginHandler(BaseHandler): async def post(self): '''Authenticates student and login.''' - uid = self._prefix.sub('', self.get_body_argument('uid')) + # uid = self._prefix.sub('', self.get_body_argument('uid')) + uid = self.get_body_argument('uid') password = self.get_body_argument('pw') headers = { 'remote_ip': self.request.remote_ip, @@ -187,7 +132,7 @@ class LoginHandler(BaseHandler): error = await self.testapp.login(uid, password, headers) - if error: + if error is not None: await asyncio.sleep(3) # delay to avoid spamming the server... self.render('login.html', error=self._error_msg[error]) else: @@ -245,14 +190,16 @@ class RootHandler(BaseHandler): ''' uid = self.current_user - logging.debug('"%s" GET /', uid) + logger.debug('"%s" GET /', uid) if uid == '0': self.redirect('/admin') return - test = await self.testapp.get_test_or_generate(uid) - self.render('test.html', t=test, md=md_to_html, templ=self._templates) + test = self.testapp.get_test(uid) + name = self.testapp.get_name(uid) + self.render('test.html', + t=test, uid=uid, name=name, md=md_to_html, templ=self._templates) # --- POST @@ -260,22 +207,21 @@ class RootHandler(BaseHandler): async def post(self): ''' Receives answers, fixes some html weirdness, corrects test and - sends back the grade. + renders the grade. self.request.arguments = {'answered-0': [b'on'], '0': [b'13.45']} builds dictionary ans={0: 'answer0', 1:, 'answer1', ...} unanswered questions not included. ''' - timeit_start = timer() # performance timer + starttime = timer() # performance timer uid = self.current_user - logging.debug('"%s" POST /', uid) + logger.debug('"%s" POST /', uid) - try: - test = self.testapp.get_test(uid) - except KeyError as exc: - logging.warning('"%s" POST / raised 403 Forbidden', uid) - raise tornado.web.HTTPError(403) from exc # Forbidden + test = self.testapp.get_test(uid) + if test is None: + logger.warning('"%s" submitted but no test running - Err 403', uid) + raise tornado.web.HTTPError(403) # Forbidden ans = {} for i, question in enumerate(test['questions']): @@ -296,16 +242,11 @@ class RootHandler(BaseHandler): # submit answered questions, correct await self.testapp.submit_test(uid, ans) - # show final grade and grades of other tests in the database - # allgrades = self.testapp.get_student_grades_from_all_tests(uid) - # grade = self.testapp.get_student_grade(uid) - - self.render('grade.html', t=test) + name = self.testapp.get_name(uid) + self.render('grade.html', t=test, uid=uid, name=name) self.clear_cookie('perguntations_user') self.testapp.logout(uid) - - timeit_finish = timer() - logging.info(' elapsed time: %fs', timeit_finish-timeit_start) + logger.info(' elapsed time: %fs', timer() - starttime) # ---------------------------------------------------------------------------- @@ -337,8 +278,10 @@ class AdminWebservice(BaseHandler): async def get(self): '''admin webservices that do not change state''' cmd = self.get_query_argument('cmd') + logger.debug('GET /adminwebservice %s', cmd) + if cmd == 'testcsv': - test_ref, data = self.testapp.get_test_csv() + test_ref, data = self.testapp.get_grades_csv() self.set_header('Content-Type', 'text/csv') self.set_header('content-Disposition', f'attachment; filename={test_ref}.csv') @@ -346,7 +289,7 @@ class AdminWebservice(BaseHandler): await self.flush() if cmd == 'questionscsv': - test_ref, data = self.testapp.get_questions_csv() + test_ref, data = self.testapp.get_detailed_grades_csv() self.set_header('Content-Type', 'text/csv') self.set_header('content-Disposition', f'attachment; filename={test_ref}-detailed.csv') @@ -359,6 +302,7 @@ class AdminWebservice(BaseHandler): class AdminHandler(BaseHandler): '''Handle /admin''' + # --- GET @tornado.web.authenticated @admin_only async def get(self): @@ -366,24 +310,18 @@ class AdminHandler(BaseHandler): Admin page. ''' cmd = self.get_query_argument('cmd', default=None) + logger.debug('GET /admin (cmd=%s)', cmd) - if cmd == 'students_table': - data = {'data': self.testapp.get_students_state()} - self.write(json.dumps(data, default=str)) + if cmd is None: + self.render('admin.html') elif cmd == 'test': - data = { - 'data': { - 'title': self.testapp.testfactory['title'], - 'ref': self.testapp.testfactory['ref'], - 'filename': self.testapp.testfactory['testfile'], - 'database': self.testapp.testfactory['database'], - 'answers_dir': self.testapp.testfactory['answers_dir'], - } - } + data = { 'data': self.testapp.get_test_config() } + self.write(json.dumps(data, default=str)) + elif cmd == 'students_table': + data = {'data': self.testapp.get_students_state()} self.write(json.dumps(data, default=str)) - else: - self.render('admin.html') + # --- POST @tornado.web.authenticated @admin_only async def post(self): @@ -392,6 +330,7 @@ class AdminHandler(BaseHandler): ''' cmd = self.get_body_argument('cmd', None) value = self.get_body_argument('value', None) + logger.debug('POST /admin (cmd=%s, value=%s)') if cmd == 'allow': self.testapp.allow_student(value) @@ -402,15 +341,14 @@ class AdminHandler(BaseHandler): elif cmd == 'deny_all': self.testapp.deny_all_students() elif cmd == 'reset_password': - await self.testapp.update_student_password(uid=value, password='') - - elif cmd == 'insert_student': + await self.testapp.set_password(uid=value, pw='') + elif cmd == 'insert_student' and value is not None: student = json.loads(value) self.testapp.insert_new_student(uid=student['number'], name=student['name']) else: - logging.error('Unknown command: "%s"', cmd) + logger.error('Unknown command: "%s"', cmd) # ---------------------------------------------------------------------------- @@ -429,16 +367,16 @@ class FileHandler(BaseHandler): Returns requested file. Files are obtained from the 'public' directory of each question. ''' - uid = self.current_user ref = self.get_query_argument('ref', None) image = self.get_query_argument('image', None) + logger.debug('GET /file (ref=%s, image=%s)', ref, image) content_type = mimetypes.guess_type(image)[0] if uid != '0': test = self.testapp.get_student_test(uid) else: - logging.error('FIXME Cannot serve images for review.') + logger.error('FIXME Cannot serve images for review.') raise tornado.web.HTTPError(404) # FIXME admin if test is None: @@ -447,15 +385,15 @@ class FileHandler(BaseHandler): for question in test['questions']: # search for the question that contains the image if question['ref'] == ref: - filepath = path.join(question['path'], 'public', image) + filepath = path.join(question['path'], b'public', image) try: file = open(filepath, 'rb') except FileNotFoundError: - logging.error('File not found: %s', filepath) + logger.error('File not found: %s', filepath) except PermissionError: - logging.error('No permission: %s', filepath) + logger.error('No permission: %s', filepath) except OSError: - logging.error('Error opening file: %s', filepath) + logger.error('Error opening file: %s', filepath) else: data = file.read() file.close() @@ -494,26 +432,29 @@ class ReviewHandler(BaseHandler): Opens JSON file with a given corrected test and renders it ''' test_id = self.get_query_argument('test_id', None) - logging.info('Review test %s.', test_id) + logger.info('Review test %s.', test_id) fname = self.testapp.get_json_filename_of_test(test_id) if fname is None: raise tornado.web.HTTPError(404) # Not Found try: - with open(path.expanduser(fname)) as jsonfile: + with open(path.expanduser(fname), encoding='utf-8') as jsonfile: test = json.load(jsonfile) except OSError: msg = f'Cannot open "{fname}" for review.' - logging.error(msg) + logger.error(msg) raise tornado.web.HTTPError(status_code=404, reason=msg) from None except json.JSONDecodeError as exc: msg = f'JSON error in "{fname}": {exc}' - logging.error(msg) + logger.error(msg) raise tornado.web.HTTPError(status_code=404, reason=msg) - self.render('review.html', t=test, md=md_to_html, - templ=self._templates) + uid = test['student'] + name = self.testapp.get_name(uid) + + self.render('review.html', t=test, uid=uid, name=name, + md=md_to_html, templ=self._templates) # ---------------------------------------------------------------------------- @@ -524,7 +465,7 @@ def signal_handler(*_): reply = input(' --> Stop webserver? (yes/no) ') if reply.lower() == 'yes': tornado.ioloop.IOLoop.current().stop() - logging.critical('Webserver stopped.') + logger.critical('Webserver stopped.') sys.exit(0) # ---------------------------------------------------------------------------- @@ -534,33 +475,33 @@ def run_webserver(app, ssl_opt, port, debug): ''' # --- create web application - logging.info('-----------------------------------------------------------') - logging.info('Starting WebApplication (tornado)') + logger.info('-------- Starting WebApplication (tornado) --------') try: webapp = WebApplication(app, debug=debug) except Exception: - logging.critical('Failed to start web application.') + logger.critical('Failed to start web application.') raise + # --- create httpserver try: httpserver = tornado.httpserver.HTTPServer(webapp, ssl_options=ssl_opt) except ValueError: - logging.critical('Certificates cert.pem, privkey.pem not found') + logger.critical('Certificates cert.pem, privkey.pem not found') sys.exit(1) try: httpserver.listen(port) except OSError: - logging.critical('Cannot bind port %d. Already in use?', port) + logger.critical('Cannot bind port %d. Already in use?', port) sys.exit(1) - logging.info('Webserver listening on %d... (Ctrl-C to stop)', port) + logger.info('Listening on port %d... (Ctrl-C to stop)', port) signal.signal(signal.SIGINT, signal_handler) # --- run webserver try: tornado.ioloop.IOLoop.current().start() # running... except Exception: - logging.critical('Webserver stopped!') + logger.critical('Webserver stopped!') tornado.ioloop.IOLoop.current().stop() raise diff --git a/perguntations/templates/admin.html b/perguntations/templates/admin.html index 6070ba8..cd3498f 100644 --- a/perguntations/templates/admin.html +++ b/perguntations/templates/admin.html @@ -72,7 +72,7 @@

Referência: --
Ficheiro de configuração do teste: --
- Testes em formato JSON no directório: --
+ Directório com os testes entregues: --
Base de dados: --

diff --git a/perguntations/templates/grade.html b/perguntations/templates/grade.html index 170b62f..93672ff 100644 --- a/perguntations/templates/grade.html +++ b/perguntations/templates/grade.html @@ -31,8 +31,8 @@ - {{ escape(t['student']['name']) }} - ({{ escape(t['student']['number']) }}) + {{ escape(name) }} + ({{ escape(uid) }}) diff --git a/perguntations/templates/review.html b/perguntations/templates/review.html index d4d9033..48e32f7 100644 --- a/perguntations/templates/review.html +++ b/perguntations/templates/review.html @@ -59,8 +59,8 @@

diff --git a/perguntations/templates/test.html b/perguntations/templates/test.html index 6dbb116..692c321 100644 --- a/perguntations/templates/test.html +++ b/perguntations/templates/test.html @@ -74,8 +74,8 @@ @@ -93,11 +93,11 @@
-
{{ escape(t['student']['name']) }}
+
{{ escape(name) }}
-
{{ escape(t['student']['number']) }}
+
{{ escape(uid) }}
@@ -119,7 +119,9 @@
- +
@@ -138,11 +140,16 @@
diff --git a/perguntations/test.py b/perguntations/test.py index 4269c7b..3b1908d 100644 --- a/perguntations/test.py +++ b/perguntations/test.py @@ -7,7 +7,6 @@ from datetime import datetime import json import logging from math import nan -from os import path # Logger configuration logger = logging.getLogger(__name__) @@ -26,11 +25,11 @@ class Test(dict): self['comment'] = '' # ------------------------------------------------------------------------ - def start(self, student: dict) -> None: + def start(self, uid: str) -> None: ''' - Write student id in the test and register start time + Register student id and start time in the test ''' - self['student'] = student + self['student'] = uid self['start_time'] = datetime.now() self['finish_time'] = None self['state'] = 'ACTIVE' diff --git a/perguntations/testfactory.py b/perguntations/testfactory.py index a59d7f7..8272bc2 100644 --- a/perguntations/testfactory.py +++ b/perguntations/testfactory.py @@ -47,15 +47,15 @@ class TestFactory(dict): 'duration': 0, # 0=infinite 'autosubmit': False, 'autocorrect': True, - 'debug': False, + 'debug': False, # FIXME not property of a test... 'show_ref': False, }) self.update(conf) # --- for review, we are done. no factories needed - if self['review']: - logger.info('Review mode. No questions loaded. No factories.') - return + # if self['review']: FIXME + # logger.info('Review mode. No questions loaded. No factories.') + # return # --- perform sanity checks and normalize the test questions self.sanity_checks() diff --git a/perguntations/tools.py b/perguntations/tools.py index 163eab7..a432bc8 100644 --- a/perguntations/tools.py +++ b/perguntations/tools.py @@ -19,28 +19,11 @@ import yaml logger = logging.getLogger(__name__) -# --------------------------------------------------------------------------- -def load_yaml(filename: str, default: Any = None) -> Any: - '''load data from yaml file''' - - filename = path.expanduser(filename) - try: - file = open(filename, 'r', encoding='utf-8') - except Exception as exc: - logger.error(exc) - if default is not None: - return default - raise - - with file: - try: - return yaml.safe_load(file) - except yaml.YAMLError as exc: - logger.error(str(exc).replace('\n', ' ')) - if default is not None: - return default - raise - +# ---------------------------------------------------------------------------- +def load_yaml(filename: str) -> Any: + '''load yaml file or raise exception on error''' + with open(path.expanduser(filename), 'r', encoding='utf-8') as file: + return yaml.safe_load(file) # --------------------------------------------------------------------------- def run_script(script: str, diff --git a/setup.py b/setup.py index c050093..1d132dd 100644 --- a/setup.py +++ b/setup.py @@ -28,7 +28,9 @@ setup( 'mistune', 'pyyaml>=5.1', 'pygments', - 'sqlalchemy', + # 'sqlalchemy', + 'sqlalchemy[asyncio]', + 'aiosqlite', 'bcrypt>=3.1' ], entry_points={ -- libgit2 0.21.2